From 3f43e0e9e3f7d6b26204a3ca0653cae37d514f9c Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 3 Jun 2026 14:07:59 -0700 Subject: [PATCH 1/4] fix(key_generate): exempt UI/CLI session tokens from the budget ceiling for team keys (#29612) Non-admin users creating a team key through the UI were rejected with "max_budget cannot exceed the caller's own max_budget (0.25)". The request is authenticated by a UI/CLI session token whose max_budget is the per-session chat spend cap (max_ui_session_budget, default $0.25), and the delegated-authority budget ceiling (GHSA-q775-qw9r-2r4g) treated that cap as a delegation limit. Skip the ceiling only when a session token creates a team key (data.team_id set); that key's spend is bounded by the team budget at request time. Personal keys and every other non-admin caller keep the ceiling, so a session token cannot mint an arbitrary-budget personal key. (cherry picked from commit 97ba7e1a30588b9bce87c9efc96b34bf2d1de375) --- .../key_management_endpoints.py | 9 ++ .../test_key_management_endpoints.py | 84 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index b5ab14fcd32..58ba6bda167 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -714,8 +714,17 @@ async def _common_key_generation_helper( # noqa: PLR0915 # Delegated-authority ceiling (GHSA-q775-qw9r-2r4g): a non-admin caller # with an explicit budget cannot grant a key a higher budget than their own. # Callers with max_budget=None (unlimited) can delegate any budget. + # A UI/CLI session token's max_budget is a per-session chat spend cap + # (max_ui_session_budget), not a delegation authority, so it is exempt only + # when creating a team key - that key's spend is bounded by the team budget + # 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 + ) if ( user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value + and not is_ui_session_team_key and _requested_max_budget is not None and user_api_key_dict.max_budget is not None and _requested_max_budget > user_api_key_dict.max_budget 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 c3d61943288..5d8ccd8583d 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 @@ -11477,3 +11477,87 @@ async def test_ghsa_q775_admin_bypasses_budget_ceiling(): litellm_changed_by=None, ) assert result is not None + + +@pytest.mark.asyncio +async def test_ghsa_q775_ui_session_token_team_key_exempt_from_budget_ceiling(): + """ + Regression: a UI/CLI session token (team_id=litellm-dashboard) creating a + TEAM key (data.team_id set) is exempt from the delegated-authority ceiling. + The session max_budget is a per-session chat spend cap (max_ui_session_budget, + default $0.25), not a delegation authority, and the team key's spend is bounded + by the team budget at request time. This is the team-admin key-creation flow + blocked since v1.86.x. Calls the helper directly so the ceiling runs (mocking + out _common_key_generation_helper would mock out the check under test). + """ + from litellm.constants import UI_SESSION_TOKEN_TEAM_ID + + data = GenerateKeyRequest(max_budget=500, team_id="team-abc") + 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, + ) + + with ( + patch("litellm.proxy.proxy_server.prisma_client", AsyncMock()), + patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), + patch("litellm.proxy.proxy_server.llm_router", None), + patch("litellm.proxy.proxy_server.premium_user", False), + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "default_user_id"), + ): + try: + await _common_key_generation_helper( + data=data, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + team_table=MagicMock(), + ) + except (HTTPException, ProxyException) as err: + msg = str(getattr(err, "detail", "")) + str(getattr(err, "message", "")) + assert ( + "cannot exceed" not in msg.lower() + ), "UI/CLI session token creating a team key must be exempt from the ceiling" + + +@pytest.mark.asyncio +async def test_ghsa_q775_ui_session_token_personal_key_still_capped(): + """ + Security regression for GHSA-q775: the session-token exemption must NOT extend + to personal keys. A UI/CLI session token (team_id=litellm-dashboard) creating a + key with no data.team_id is still bound by the ceiling; otherwise a session + token - or a leaked one, whose blast radius is the $0.25 chat cap - could mint + an arbitrary-budget personal key, the exact escalation GHSA-q775 closed. Unlike + a team key, nothing else bounds a personal key's spend. + """ + from litellm.constants import UI_SESSION_TOKEN_TEAM_ID + + data = GenerateKeyRequest(max_budget=500) + 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), + ): + 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() From d97882eea58f16be5d7e6c90be633902dc988748 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:06:48 +0000 Subject: [PATCH 2/4] =?UTF-8?q?bump:=20version=201.87.1=20=E2=86=92=201.87?= =?UTF-8?q?.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3e0b99a2205..e9c68d09dd9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm" -version = "1.87.1" +version = "1.87.2" description = "Library to easily interface with LLM API providers" readme = "README.md" requires-python = ">=3.10, <3.14" @@ -253,7 +253,7 @@ source-exclude = [ profile = "black" [tool.commitizen] -version = "1.87.1" +version = "1.87.2" version_files = [ "pyproject.toml:^version", ] From 951012ccfed01e0b1bef262f422ef43d2da0601b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:06:48 +0000 Subject: [PATCH 3/4] chore: update uv.lock for 1.87.2 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 9c110bfc5a9..2ec92e3471c 100644 --- a/uv.lock +++ b/uv.lock @@ -3269,7 +3269,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.87.1" +version = "1.87.2" source = { editable = "." } dependencies = [ { name = "aiohttp" }, 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 4/4] 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 58ba6bda167..72bde32c51c 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 5d8ccd8583d..f279f4e52c8 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()