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 97ba7e1a30)
This commit is contained in:
yuneng-jiang
2026-06-03 23:45:09 +00:00
committed by mateo-berri
parent 173cbc9e60
commit 235d0581c9
2 changed files with 93 additions and 0 deletions
@@ -739,8 +739,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
@@ -11444,3 +11444,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()