From efeb101ec63fb2c66a3400e410dc5c9d3e5a56e7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 4 Jun 2026 01:16:13 +0000 Subject: [PATCH] fix(key_generate): harden GHSA-q775 session-token exemption against default_key_generate_params Capture _requested_team_id before the default_key_generate_params loop runs and key the UI/CLI session-token budget-ceiling exemption off it, instead of the post-defaults data.team_id. On an install that sets default_key_generate_params.team_id, a session token requesting a personal key (no explicit team_id) would otherwise have data.team_id auto-filled, flipping is_ui_session_team_key on and bypassing the delegated-authority ceiling -- the exact escalation GHSA-q775 closed. Mirrors the existing pre-defaults capture of _requested_max_budget. Adds a regression test. https://claude.ai/code/session_01RT583b1khYC3wjLrQ5hT5h --- .../key_management_endpoints.py | 7 ++- .../test_key_management_endpoints.py | 44 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 58ba6bda16..72bde32c51 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -687,6 +687,11 @@ async def _common_key_generation_helper( # noqa: PLR0915 # params can fill it, so the ceiling check only fires when the caller # explicitly requested a budget. _requested_max_budget = data.max_budget + # Same rationale for team_id: capture it before the defaults loop can inject + # one from default_key_generate_params, so the session-token exemption below + # only fires when the caller actually requested a team key (not a personal + # key whose team_id was auto-filled by config defaults). + _requested_team_id = data.team_id # check if user set default key/generate params on config.yaml if litellm.default_key_generate_params is not None: @@ -720,7 +725,7 @@ async def _common_key_generation_helper( # noqa: PLR0915 # at request time. Personal keys keep the ceiling; nothing else bounds them. is_ui_session_team_key = ( user_api_key_dict.team_id == UI_SESSION_TOKEN_TEAM_ID - and data.team_id is not None + and _requested_team_id is not None ) if ( user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value 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 5d8ccd8583..f279f4e52c 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 @@ -11561,3 +11561,47 @@ async def test_ghsa_q775_ui_session_token_personal_key_still_capped(): msg = str(getattr(err, "detail", "")) + str(getattr(err, "message", "")) assert str(code) == "400" assert "cannot exceed" in msg.lower() + + +@pytest.mark.asyncio +async def test_ghsa_q775_ui_session_token_default_team_id_personal_key_still_capped(): + """ + Security regression for GHSA-q775: the session-token exemption must key off the + CALLER-supplied team_id, not one injected by default_key_generate_params. On an + install that configures default_key_generate_params.team_id, a UI/CLI session + token (team_id=litellm-dashboard) requesting a personal key (no explicit team_id) + has data.team_id auto-filled by the defaults loop. The ceiling must STILL fire: + if the exemption read the post-defaults data.team_id it would flip on and let a + leaked session token (blast radius $0.25) mint an arbitrary-budget key. + """ + from litellm.constants import UI_SESSION_TOKEN_TEAM_ID + + data = GenerateKeyRequest(max_budget=500) + assert data.team_id is None # caller did not request a team key + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-ui-session", + user_id="user-1", + team_id=UI_SESSION_TOKEN_TEAM_ID, + max_budget=0.25, + ) + + mock_prisma_client = AsyncMock() + + with ( + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), + patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), + patch("litellm.proxy.proxy_server.user_custom_key_generate", None), + patch("litellm.default_key_generate_params", {"team_id": "team-default"}), + ): + with pytest.raises((HTTPException, ProxyException)) as exc_info: + await generate_key_fn( + data=data, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + err = exc_info.value + code = getattr(err, "status_code", None) or getattr(err, "code", None) + msg = str(getattr(err, "detail", "")) + str(getattr(err, "message", "")) + assert str(code) == "400" + assert "cannot exceed" in msg.lower()