From 74cb3829325edf826e07c34a739b90223976057c Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 25 Jul 2026 15:59:13 -0700 Subject: [PATCH] fix(proxy): cap /key/list user_email lookup at 1000 users A broad substring like @ matches every user, and the unbounded lookup materialized the full user table in proxy memory before building the key filter. Bound it with take so the resolved id list and the IN clause stay a fixed size --- .../proxy/management_endpoints/key_management_endpoints.py | 6 ++++-- .../management_endpoints/test_key_management_endpoints.py | 3 +++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index bcd3184a993..35cf4772426 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -5176,6 +5176,7 @@ async def get_member_team_ids( VALID_EXPIRES_FILTER_VALUES = frozenset({"active", "expired"}) +USER_EMAIL_KEY_FILTER_MAX_USERS = 1000 @router.get( @@ -5195,7 +5196,7 @@ async def list_keys( ), user_email: str | None = Query( None, - description="Filter keys by the owning user's email. Case-insensitive substring match against the user table; only keys whose user_id belongs to a matching user are returned.", + description="Filter keys by the owning user's email. Case-insensitive substring match against the user table, capped at the first 1000 matching users; only keys whose user_id belongs to a matching user are returned.", ), team_id: Optional[str] = Query(None, description="Filter keys by team ID"), organization_id: Optional[str] = Query(None, description="Filter keys by organization ID"), @@ -5329,7 +5330,8 @@ async def list_keys( [ user.user_id for user in await UserRepository(prisma_client).table.find_many( - where={"user_email": {"contains": user_email, "mode": "insensitive"}} + where={"user_email": {"contains": user_email, "mode": "insensitive"}}, + take=USER_EMAIL_KEY_FILTER_MAX_USERS, ) ] if user_email and isinstance(user_email, str) diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index c1b24ccea56..329f0dd3bf1 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -6354,6 +6354,9 @@ async def test_list_keys_user_email_resolves_to_user_ids(): assert mock_find_many.call_args.kwargs["where"] == { "user_email": {"contains": "alias@example.com", "mode": "insensitive"} } + assert mock_find_many.call_args.kwargs["take"] == 1000, ( + "user lookup must be bounded; a broad substring like '@' matches every user" + ) assert mock_list_key_helper.call_args.kwargs["user_ids_for_email"] == ["user-1", "user-2"] mock_find_many.return_value = [] diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 2fd76591f8d..af06c4eed3a 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -42813,7 +42813,7 @@ export interface operations { size?: number; /** @description Filter keys by user ID. Exact match by default; set substring_matching=true (admin only) for case-insensitive substring matching. */ user_id?: string | null; - /** @description Filter keys by the owning user's email. Case-insensitive substring match against the user table; only keys whose user_id belongs to a matching user are returned. */ + /** @description Filter keys by the owning user's email. Case-insensitive substring match against the user table, capped at the first 1000 matching users; only keys whose user_id belongs to a matching user are returned. */ user_email?: string | null; /** @description Filter keys by team ID */ team_id?: string | null;