diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index b5ab14fcd32..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: @@ -714,8 +719,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 _requested_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/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", ] 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..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 @@ -11477,3 +11477,131 @@ 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() + + +@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() 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" },