mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
Merge pull request #29636 from BerriAI/litellm_cherrypick_1_87_2
chore(release): backport #29612 (session-token budget-ceiling exemption) into stable/1.87.x and cut 1.87.2
This commit is contained in:
commit
cc2aff371f
4 changed files with 145 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -3269,7 +3269,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.87.1"
|
||||
version = "1.87.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue