From d0c1d2be8a82723d458eaf133159ad8576bd9a8b Mon Sep 17 00:00:00 2001 From: Shivam Rawat Date: Fri, 7 Aug 2026 18:08:33 -0700 Subject: [PATCH] feat(key_management): let any authenticated user resolve a raw key via /key/info Possession of the raw sk- key already lets the holder call /key/info with the key itself as the bearer token, so resolving raw key -> key info for any authenticated caller discloses nothing new. Lookups by hashed token remain restricted to admins, the key's owner, and teammates --- .../key_management_endpoints.py | 7 + .../test_key_management_endpoints.py | 146 ++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index e4def45892b..99631377541 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -6351,6 +6351,12 @@ async def _can_user_query_key_info( ) -> bool: """ Helper to check if the user has access to the key's info + + Any authenticated caller who presents the raw key value (i.e. a preimage of the + stored token hash) is allowed: possession of the raw key already grants the + ability to call /key/info with that key as the bearer token, so resolving + raw key -> key info discloses nothing new. Lookups by hashed token remain + restricted to admins, the key's owner, and the key's teammates. """ if ( ( @@ -6359,6 +6365,7 @@ async def _can_user_query_key_info( ) or user_api_key_dict.api_key == key or key_info.user_id == user_api_key_dict.user_id + or (key is not None and hash_token(token=key) == key_info.token) or await TeamMemberPermissionChecks.user_belongs_to_keys_team( user_api_key_dict=user_api_key_dict, existing_key_row=key_info, 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 cf9aa477112..fc349f8448d 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 @@ -15323,3 +15323,149 @@ async def test_rotate_master_key_rotates_sso_identity_assertions( prisma_client=mock_prisma_client, new_master_key="sk-new-master-key", ) + + +@pytest.mark.asyncio +async def test_can_user_query_key_info_raw_key_possession_allows_any_user(): + """ + Any authenticated user who presents the raw sk- key value can query that + key's info: possessing the raw key already lets them call /key/info with + the key itself as the bearer token, so this discloses nothing new. + """ + from litellm.proxy._types import hash_token + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _can_user_query_key_info, + ) + + raw_key = "sk-raw-key-owned-by-someone-else" + key_info = LiteLLM_VerificationToken( + token=hash_token(raw_key), + user_id="key-owner", + team_id=None, + key_alias="prod-batch-alias", + ) + caller = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="unrelated-user", + api_key="hashed-caller-token", + ) + + assert ( + await _can_user_query_key_info( + user_api_key_dict=caller, + key=raw_key, + key_info=key_info, + ) + is True + ) + + +@pytest.mark.asyncio +async def test_can_user_query_key_info_hashed_token_still_forbidden(): + """ + Querying by hashed token (e.g. copied from spend logs) must stay + restricted to admins, the key's owner, and teammates. + """ + from litellm.proxy._types import hash_token + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _can_user_query_key_info, + ) + + raw_key = "sk-raw-key-owned-by-someone-else" + key_info = LiteLLM_VerificationToken( + token=hash_token(raw_key), + user_id="key-owner", + team_id=None, + key_alias="prod-batch-alias", + ) + caller = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="unrelated-user", + api_key="hashed-caller-token", + ) + + assert ( + await _can_user_query_key_info( + user_api_key_dict=caller, + key=hash_token(raw_key), + key_info=key_info, + ) + is False + ) + + +@pytest.mark.asyncio +async def test_info_key_fn_resolves_alias_from_raw_key_for_any_user(monkeypatch): + """ + End-to-end through /key/info: a non-admin user unrelated to the key can + resolve raw sk- key -> key_alias. + """ + from litellm.proxy._types import hash_token + from litellm.proxy.management_endpoints.key_management_endpoints import info_key_fn + + raw_key = "sk-raw-key-owned-by-someone-else" + key_row = LiteLLM_VerificationToken( + token=hash_token(raw_key), + user_id="key-owner", + team_id=None, + key_alias="prod-batch-alias", + ) + + mock_prisma_client = AsyncMock() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", AsyncMock()) + mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock( + return_value=key_row + ) + + caller = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="unrelated-user", + api_key="hashed-caller-token", + ) + + result = await info_key_fn(key=raw_key, user_api_key_dict=caller) + + assert result["info"]["key_alias"] == "prod-batch-alias" + assert "token" not in result["info"] + + find_unique_kwargs = ( + mock_prisma_client.db.litellm_verificationtoken.find_unique.call_args.kwargs + ) + assert find_unique_kwargs["where"] == {"token": hash_token(raw_key)} + + +@pytest.mark.asyncio +async def test_info_key_fn_hashed_lookup_still_403_for_unrelated_user(monkeypatch): + """ + End-to-end through /key/info: the same unrelated user querying by hashed + token still gets a 403. + """ + from litellm.proxy._types import hash_token + from litellm.proxy.management_endpoints.key_management_endpoints import info_key_fn + + raw_key = "sk-raw-key-owned-by-someone-else" + key_row = LiteLLM_VerificationToken( + token=hash_token(raw_key), + user_id="key-owner", + team_id=None, + key_alias="prod-batch-alias", + ) + + mock_prisma_client = AsyncMock() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", AsyncMock()) + mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock( + return_value=key_row + ) + + caller = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="unrelated-user", + api_key="hashed-caller-token", + ) + + with pytest.raises(ProxyException) as exc_info: + await info_key_fn(key=hash_token(raw_key), user_api_key_dict=caller) + + assert int(exc_info.value.code) == 403