From d0368bcfa978461b1967a0786c23ade711414a0a Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 13 May 2026 20:19:18 -0700 Subject: [PATCH 01/10] fix: block NaN/Inf budget bypass and add missing non-admin guards Addresses three security issues: GHSA-wvg4-6222-3q4r: /user/update exposes max_budget, soft_budget, spend to self-editing non-admin users with no server-side guard. Non-admin callers now receive HTTP 403 if any of those fields appear in the update payload. GHSA-q775-qw9r-2r4g: _enforce_upperbound_key_params returned early (no-op) when upperbound_key_generate_params was absent from config, letting any authenticated user generate a key with unlimited max_budget. Fix adds a delegated-authority ceiling in _common_key_generation_helper: non-admins cannot grant a key more budget than their own key carries. GHSA-2rv4-xv66-fpjg: float('nan') passes every `value < 0` guard because nan < 0 is False in Python, and spend >= nan is always False, permanently disabling budget enforcement for any entity carrying a NaN max_budget. All write-time budget guards now use `not math.isfinite(v) or v < 0`. _enforce_upperbound_key_params validates finiteness unconditionally (before the early-return). All spend-enforcement comparisons in auth_checks.py are now guarded with math.isfinite(max_budget) as defense-in-depth. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- litellm/proxy/auth/auth_checks.py | 27 ++++--- .../budget_management_endpoints.py | 26 ++++--- .../internal_user_endpoints.py | 13 ++++ .../key_management_endpoints.py | 72 ++++++++++++++++--- .../organization_endpoints.py | 26 ++++--- .../management_endpoints/team_endpoints.py | 37 ++++++---- .../test_budget_endpoints.py | 8 +-- 7 files changed, 159 insertions(+), 50 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 0b30999aa21b..fa047cd4477d 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -10,6 +10,7 @@ """ import asyncio +import math import re import time from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Type, Union, cast @@ -328,7 +329,10 @@ def _global_proxy_budget_check( and route != "/v1/models" and route != "/models" ): - if global_proxy_spend > litellm.max_budget: + if ( + math.isfinite(litellm.max_budget) + and global_proxy_spend > litellm.max_budget + ): raise litellm.BudgetExceededError( current_cost=global_proxy_spend, max_budget=litellm.max_budget ) @@ -645,7 +649,7 @@ async def common_checks( # noqa: PLR0915 counter_key=f"spend:user:{user_object.user_id}", fallback_spend=user_object.spend or 0.0, ) - if user_spend >= user_budget: + if math.isfinite(user_budget) and user_spend >= user_budget: raise litellm.BudgetExceededError( current_cost=user_spend, max_budget=user_budget, @@ -3280,7 +3284,10 @@ async def _virtual_key_max_budget_check( # collect information for alerting # #################################### - if spend >= valid_token.max_budget: + # Defense-in-depth (GHSA-2rv4-xv66-fpjg): spend >= NaN is always False, + # so a NaN max_budget would silently disable enforcement. Treat a + # non-finite max_budget as "no configured limit" rather than as a bypass. + if math.isfinite(valid_token.max_budget) and spend >= valid_token.max_budget: raise litellm.BudgetExceededError( current_cost=spend, max_budget=valid_token.max_budget, @@ -3313,7 +3320,7 @@ async def _virtual_key_multi_budget_check( counter_key=counter_key, fallback_spend=0.0, ) - if window_spend >= w["max_budget"]: + if math.isfinite(w["max_budget"]) and window_spend >= w["max_budget"]: raise litellm.BudgetExceededError( current_cost=window_spend, max_budget=w["max_budget"], @@ -3568,7 +3575,10 @@ async def _check_team_member_budget( fallback_spend=team_member_spend, ) - if team_member_spend >= team_member_budget: + if ( + math.isfinite(team_member_budget) + and team_member_spend >= team_member_budget + ): raise litellm.BudgetExceededError( current_cost=team_member_spend, max_budget=team_member_budget, @@ -3650,7 +3660,7 @@ async def _team_max_budget_check( fallback_spend=team_object.spend or 0.0, ) - if spend > team_object.max_budget: + if math.isfinite(team_object.max_budget) and spend > team_object.max_budget: if valid_token: call_info = CallInfo( token=valid_token.token, @@ -3698,7 +3708,7 @@ async def _team_multi_budget_check( counter_key=counter_key, fallback_spend=0.0, ) - if window_spend >= w["max_budget"]: + if math.isfinite(w["max_budget"]) and window_spend >= w["max_budget"]: raise litellm.BudgetExceededError( current_cost=window_spend, max_budget=w["max_budget"], @@ -3812,6 +3822,7 @@ async def _project_max_budget_check( if ( max_budget is not None and project_object.spend is not None + and math.isfinite(max_budget) and project_object.spend > max_budget ): if valid_token: @@ -4004,7 +4015,7 @@ async def _organization_max_budget_check( ) # Check if organization spend exceeds max budget - if org_spend >= org_max_budget: + if math.isfinite(org_max_budget) and org_spend >= org_max_budget: # Trigger budget alert call_info = CallInfo( token=valid_token.token, diff --git a/litellm/proxy/management_endpoints/budget_management_endpoints.py b/litellm/proxy/management_endpoints/budget_management_endpoints.py index 81b133e6c814..60dc7827a6f0 100644 --- a/litellm/proxy/management_endpoints/budget_management_endpoints.py +++ b/litellm/proxy/management_endpoints/budget_management_endpoints.py @@ -12,6 +12,8 @@ """ #### BUDGET TABLE MANAGEMENT #### +import math + from fastapi import APIRouter, Depends, HTTPException from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time @@ -57,18 +59,22 @@ async def new_budget( ) # Validate budget values are not negative - if budget_obj.max_budget is not None and budget_obj.max_budget < 0: + if budget_obj.max_budget is not None and ( + not math.isfinite(budget_obj.max_budget) or budget_obj.max_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"max_budget cannot be negative. Received: {budget_obj.max_budget}" + "error": f"max_budget must be a non-negative finite number. Received: {budget_obj.max_budget}" }, ) - if budget_obj.soft_budget is not None and budget_obj.soft_budget < 0: + if budget_obj.soft_budget is not None and ( + not math.isfinite(budget_obj.soft_budget) or budget_obj.soft_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"soft_budget cannot be negative. Received: {budget_obj.soft_budget}" + "error": f"soft_budget must be a non-negative finite number. Received: {budget_obj.soft_budget}" }, ) @@ -146,18 +152,22 @@ async def update_budget( raise HTTPException(status_code=400, detail={"error": "budget_id is required"}) # Validate budget values are not negative - if budget_obj.max_budget is not None and budget_obj.max_budget < 0: + if budget_obj.max_budget is not None and ( + not math.isfinite(budget_obj.max_budget) or budget_obj.max_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"max_budget cannot be negative. Received: {budget_obj.max_budget}" + "error": f"max_budget must be a non-negative finite number. Received: {budget_obj.max_budget}" }, ) - if budget_obj.soft_budget is not None and budget_obj.soft_budget < 0: + if budget_obj.soft_budget is not None and ( + not math.isfinite(budget_obj.soft_budget) or budget_obj.soft_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"soft_budget cannot be negative. Received: {budget_obj.soft_budget}" + "error": f"soft_budget must be a non-negative finite number. Received: {budget_obj.soft_budget}" }, ) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 6f73c6a632dd..1534e74f6b2a 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1235,6 +1235,19 @@ async def _update_single_user_helper( }, ) + # Non-admins cannot modify budget-sensitive fields even on their own record + # (GHSA-wvg4-6222-3q4r). + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value: + _protected_fields = ("max_budget", "soft_budget", "spend") + for _field in _protected_fields: + if _field in non_default_values: + raise HTTPException( + status_code=403, + detail={ + "error": f"Non-admin users cannot modify '{_field}'. Contact your proxy admin." + }, + ) + existing_metadata = ( cast(Dict, getattr(existing_user_row, "metadata", {}) or {}) if existing_user_row is not None diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 30655746cbba..9b48567d9ff9 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -13,6 +13,7 @@ import copy import inspect import json +import math import os import re import secrets @@ -608,6 +609,11 @@ async def validate_team_id_used_in_service_account_request( return True +_BUDGET_NUMERIC_KEYS = frozenset( + ["max_budget", "soft_budget", "max_parallel_requests", "tpm_limit", "rpm_limit"] +) + + def _enforce_upperbound_key_params( data: Union[GenerateKeyRequest, UpdateKeyRequest], fill_defaults: bool = True, @@ -618,6 +624,21 @@ def _enforce_upperbound_key_params( For key generation (fill_defaults=True): fills None values with upperbound defaults. For key update (fill_defaults=False): only validates explicitly provided values. """ + # Always reject NaN / Inf regardless of whether an upperbound config is set + # (GHSA-2rv4-xv66-fpjg): float('nan') passes every `< 0` check because + # nan < 0 is False, and spend >= nan is always False, permanently disabling + # budget enforcement for any key that carries it. + for elem in data: + key, value = elem + if key in _BUDGET_NUMERIC_KEYS and value is not None: + if not math.isfinite(value): + raise HTTPException( + status_code=400, + detail={ + "error": f"{key} must be a finite number. Received: {value}" + }, + ) + if litellm.upperbound_key_generate_params is None: return @@ -710,6 +731,28 @@ async def _common_key_generation_helper( # noqa: PLR0915 # check if user set upperbound key/generate params on config.yaml _enforce_upperbound_key_params(data, fill_defaults=True) + # Delegated-authority ceiling (GHSA-q775-qw9r-2r4g): a non-admin caller + # cannot grant a key more budget than the caller's own key carries. This + # prevents budget escalation when no upperbound_key_generate_params config + # is present. + from litellm.proxy._types import LitellmUserRoles + + if ( + user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value + and data.max_budget is not None + and user_api_key_dict.max_budget is not None + and data.max_budget > user_api_key_dict.max_budget + ): + raise HTTPException( + status_code=400, + detail={ + "error": ( + f"max_budget ({data.max_budget}) cannot exceed the caller's " + f"own max_budget ({user_api_key_dict.max_budget})." + ) + }, + ) + # APPLY ENTERPRISE KEY MANAGEMENT PARAMS try: from litellm_enterprise.proxy.management_endpoints.key_management_endpoints import ( @@ -1416,19 +1459,24 @@ async def generate_key_fn( await check_org_admin_can_generate_keys(user_api_key_dict=user_api_key_dict) - # Validate budget values are not negative - if data.max_budget is not None and data.max_budget < 0: + # Validate budget values are not negative and are finite numbers + # (GHSA-2rv4-xv66-fpjg): float('nan') passes `< 0` because nan < 0 is False. + if data.max_budget is not None and ( + not math.isfinite(data.max_budget) or data.max_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"max_budget cannot be negative. Received: {data.max_budget}" + "error": f"max_budget must be a non-negative finite number. Received: {data.max_budget}" }, ) - if data.soft_budget is not None and data.soft_budget < 0: + if data.soft_budget is not None and ( + not math.isfinite(data.soft_budget) or data.soft_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"soft_budget cannot be negative. Received: {data.soft_budget}" + "error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}" }, ) @@ -1880,10 +1928,12 @@ def _validate_max_budget(max_budget: Optional[float]) -> None: Raises: HTTPException: If max_budget is negative """ - if max_budget is not None and max_budget < 0: + if max_budget is not None and (not math.isfinite(max_budget) or max_budget < 0): raise HTTPException( status_code=400, - detail={"error": f"max_budget cannot be negative. Received: {max_budget}"}, + detail={ + "error": f"max_budget must be a non-negative finite number. Received: {max_budget}" + }, ) @@ -2413,12 +2463,14 @@ async def update_key_fn( # noqa: PLR0915 ) try: - # Validate budget values are not negative - if data.max_budget is not None and data.max_budget < 0: + # Validate budget values are not negative and are finite numbers + if data.max_budget is not None and ( + not math.isfinite(data.max_budget) or data.max_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"max_budget cannot be negative. Received: {data.max_budget}" + "error": f"max_budget must be a non-negative finite number. Received: {data.max_budget}" }, ) diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index ee683f322a1f..4d4ed53aaa8b 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -1,3 +1,5 @@ +import math + """ Endpoints for /organization operations @@ -220,18 +222,22 @@ async def new_organization( ) # Validate budget values are not negative - if data.max_budget is not None and data.max_budget < 0: + if data.max_budget is not None and ( + not math.isfinite(data.max_budget) or data.max_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"max_budget cannot be negative. Received: {data.max_budget}" + "error": f"max_budget must be a non-negative finite number. Received: {data.max_budget}" }, ) - if data.soft_budget is not None and data.soft_budget < 0: + if data.soft_budget is not None and ( + not math.isfinite(data.soft_budget) or data.soft_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"soft_budget cannot be negative. Received: {data.soft_budget}" + "error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}" }, ) @@ -482,18 +488,22 @@ async def update_organization( data = LiteLLM_OrganizationTableUpdate(**raw_data_with_flat_budget_fields) # Validate budget values are not negative - if data.max_budget is not None and data.max_budget < 0: + if data.max_budget is not None and ( + not math.isfinite(data.max_budget) or data.max_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"max_budget cannot be negative. Received: {data.max_budget}" + "error": f"max_budget must be a non-negative finite number. Received: {data.max_budget}" }, ) - if data.soft_budget is not None and data.soft_budget < 0: + if data.soft_budget is not None and ( + not math.isfinite(data.soft_budget) or data.soft_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"soft_budget cannot be negative. Received: {data.soft_budget}" + "error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}" }, ) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 65bcca23c30e..35e3d196e9ea 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -10,6 +10,7 @@ """ import asyncio +import math import json import traceback from datetime import datetime, timezone @@ -914,25 +915,31 @@ async def new_team( # noqa: PLR0915 raise HTTPException(status_code=500, detail={"error": "No db connected"}) # Validate budget values are not negative - if data.max_budget is not None and data.max_budget < 0: + if data.max_budget is not None and ( + not math.isfinite(data.max_budget) or data.max_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"max_budget cannot be negative. Received: {data.max_budget}" + "error": f"max_budget must be a non-negative finite number. Received: {data.max_budget}" }, ) - if data.team_member_budget is not None and data.team_member_budget < 0: + if data.team_member_budget is not None and ( + not math.isfinite(data.team_member_budget) or data.team_member_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"team_member_budget cannot be negative. Received: {data.team_member_budget}" + "error": f"team_member_budget must be a non-negative finite number. Received: {data.team_member_budget}" }, ) - if data.soft_budget is not None and data.soft_budget < 0: + if data.soft_budget is not None and ( + not math.isfinite(data.soft_budget) or data.soft_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"soft_budget cannot be negative. Received: {data.soft_budget}" + "error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}" }, ) @@ -1595,25 +1602,31 @@ async def update_team( # noqa: PLR0915 verbose_proxy_logger.debug("/team/update - %s", data) # Validate budget values are not negative - if data.max_budget is not None and data.max_budget < 0: + if data.max_budget is not None and ( + not math.isfinite(data.max_budget) or data.max_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"max_budget cannot be negative. Received: {data.max_budget}" + "error": f"max_budget must be a non-negative finite number. Received: {data.max_budget}" }, ) - if data.team_member_budget is not None and data.team_member_budget < 0: + if data.team_member_budget is not None and ( + not math.isfinite(data.team_member_budget) or data.team_member_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"team_member_budget cannot be negative. Received: {data.team_member_budget}" + "error": f"team_member_budget must be a non-negative finite number. Received: {data.team_member_budget}" }, ) - if data.soft_budget is not None and data.soft_budget < 0: + if data.soft_budget is not None and ( + not math.isfinite(data.soft_budget) or data.soft_budget < 0 + ): raise HTTPException( status_code=400, detail={ - "error": f"soft_budget cannot be negative. Received: {data.soft_budget}" + "error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}" }, ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_budget_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_budget_endpoints.py index b15b9d622e4c..d924d5ecdfe3 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_budget_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_budget_endpoints.py @@ -186,7 +186,7 @@ async def test_new_budget_negative_max_budget(client_and_mocks): assert resp.status_code == 400, resp.text detail = resp.json()["detail"] - assert "max_budget cannot be negative" in str(detail) + assert "max_budget must be a non-negative finite number" in str(detail) @pytest.mark.asyncio @@ -204,7 +204,7 @@ async def test_new_budget_negative_soft_budget(client_and_mocks): assert resp.status_code == 400, resp.text detail = resp.json()["detail"] - assert "soft_budget cannot be negative" in str(detail) + assert "soft_budget must be a non-negative finite number" in str(detail) @pytest.mark.asyncio @@ -222,7 +222,7 @@ async def test_update_budget_negative_max_budget(client_and_mocks): assert resp.status_code == 400, resp.text detail = resp.json()["detail"] - assert "max_budget cannot be negative" in str(detail) + assert "max_budget must be a non-negative finite number" in str(detail) @pytest.mark.asyncio @@ -240,7 +240,7 @@ async def test_update_budget_negative_soft_budget(client_and_mocks): assert resp.status_code == 400, resp.text detail = resp.json()["detail"] - assert "soft_budget cannot be negative" in str(detail) + assert "soft_budget must be a non-negative finite number" in str(detail) @pytest.mark.asyncio From a0724d539e01aa873e59794fe4c5dd162b8b5584 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 13 May 2026 20:37:12 -0700 Subject: [PATCH 02/10] fix: close budget ceiling bypass for callers with no max_budget (GHSA-q775) Non-admin callers whose API key has no explicit max_budget (None) could bypass the delegated-authority ceiling and create keys with arbitrary budgets. Now blocks budget assignment when caller has no budget configured. Also removes redundant inline import of LitellmUserRoles. Co-Authored-By: Claude Opus 4.6 --- .../key_management_endpoints.py | 33 +++-- .../test_key_management_endpoints.py | 140 ++++++++++++++++++ 2 files changed, 160 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 9b48567d9ff9..af65a0bb900d 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -735,23 +735,30 @@ async def _common_key_generation_helper( # noqa: PLR0915 # cannot grant a key more budget than the caller's own key carries. This # prevents budget escalation when no upperbound_key_generate_params config # is present. - from litellm.proxy._types import LitellmUserRoles - if ( user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value and data.max_budget is not None - and user_api_key_dict.max_budget is not None - and data.max_budget > user_api_key_dict.max_budget ): - raise HTTPException( - status_code=400, - detail={ - "error": ( - f"max_budget ({data.max_budget}) cannot exceed the caller's " - f"own max_budget ({user_api_key_dict.max_budget})." - ) - }, - ) + if user_api_key_dict.max_budget is None: + raise HTTPException( + status_code=400, + detail={ + "error": ( + f"Cannot set max_budget ({data.max_budget}) on a generated " + "key because the caller's own key has no budget configured." + ) + }, + ) + if data.max_budget > user_api_key_dict.max_budget: + raise HTTPException( + status_code=400, + detail={ + "error": ( + f"max_budget ({data.max_budget}) cannot exceed the caller's " + f"own max_budget ({user_api_key_dict.max_budget})." + ) + }, + ) # APPLY ENTERPRISE KEY MANAGEMENT PARAMS try: 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 ae3741c5e2aa..4257df078cde 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 @@ -10750,3 +10750,143 @@ async def test_regenerate_premium_gate_allows_actual_master_key_holder(): ) assert result.token == "sk-new-master" + + +# --------------------------------------------------------------------------- +# Regression tests for GHSA-q775-qw9r-2r4g: budget escalation via key/generate +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_ghsa_q775_non_admin_no_budget_cannot_set_budget(): + """ + Non-admin caller with no max_budget (None) must not be able to set + max_budget on generated keys. Before the fix, the ceiling check was + silently skipped when the caller had no budget configured. + """ + data = GenerateKeyRequest(max_budget=999999) + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-internal", + user_id="user-1", + max_budget=None, + ) + + 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 "no budget configured" in msg.lower() + + +@pytest.mark.asyncio +async def test_ghsa_q775_non_admin_cannot_exceed_own_budget(): + """ + Non-admin caller with max_budget=100 must not be able to create a key + with max_budget=500. + """ + data = GenerateKeyRequest(max_budget=500) + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-internal", + user_id="user-1", + max_budget=100, + ) + + 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_non_admin_within_budget_allowed(): + """ + Non-admin caller with max_budget=100 can create a key with max_budget=50. + """ + data = GenerateKeyRequest(max_budget=50) + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-internal", + user_id="user-1", + max_budget=100, + ) + + 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.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper", + new_callable=AsyncMock, + return_value=MagicMock(), + ), + ): + result = await generate_key_fn( + data=data, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + assert result is not None + + +@pytest.mark.asyncio +async def test_ghsa_q775_admin_bypasses_budget_ceiling(): + """ + Admin caller can set any max_budget regardless of own budget. + """ + data = GenerateKeyRequest(max_budget=999999) + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + api_key="sk-admin", + user_id="admin-1", + max_budget=None, + ) + + 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.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper", + new_callable=AsyncMock, + return_value=MagicMock(), + ), + ): + result = await generate_key_fn( + data=data, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + assert result is not None From 09bff753ff64d8a82f08c311a2ba308388d9aebc Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 13 May 2026 20:44:29 -0700 Subject: [PATCH 03/10] fix: only apply budget ceiling to explicitly requested max_budget Capture the caller-supplied max_budget before _enforce_upperbound_key_params can fill it with a default, so auto-filled defaults don't trigger the ceiling guard for non-admin users with no budget on their own key. Co-Authored-By: Claude Opus 4.6 --- .../key_management_endpoints.py | 13 ++++-- .../test_key_management_endpoints.py | 40 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index af65a0bb900d..c296dd11b3f3 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -728,6 +728,11 @@ async def _common_key_generation_helper( # noqa: PLR0915 elif key == "metadata" and value == {}: setattr(data, key, litellm.default_key_generate_params.get(key, {})) + # Capture the caller-supplied max_budget *before* upperbound params can + # fill it with a default, so the ceiling check only fires when the caller + # explicitly requested a budget. + _requested_max_budget = data.max_budget + # check if user set upperbound key/generate params on config.yaml _enforce_upperbound_key_params(data, fill_defaults=True) @@ -737,24 +742,24 @@ async def _common_key_generation_helper( # noqa: PLR0915 # is present. if ( user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value - and data.max_budget is not None + and _requested_max_budget is not None ): if user_api_key_dict.max_budget is None: raise HTTPException( status_code=400, detail={ "error": ( - f"Cannot set max_budget ({data.max_budget}) on a generated " + f"Cannot set max_budget ({_requested_max_budget}) on a generated " "key because the caller's own key has no budget configured." ) }, ) - if data.max_budget > user_api_key_dict.max_budget: + if _requested_max_budget > user_api_key_dict.max_budget: raise HTTPException( status_code=400, detail={ "error": ( - f"max_budget ({data.max_budget}) cannot exceed the caller's " + f"max_budget ({_requested_max_budget}) cannot exceed the caller's " f"own 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 4257df078cde..28e40eb86c81 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 @@ -10859,6 +10859,46 @@ async def test_ghsa_q775_non_admin_within_budget_allowed(): assert result is not None +@pytest.mark.asyncio +async def test_ghsa_q775_upperbound_default_not_rejected(): + """ + When upperbound_key_generate_params fills max_budget as a default, the + ceiling check must NOT fire — only explicitly requested budgets trigger it. + """ + data = GenerateKeyRequest() + assert data.max_budget is None + + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-internal", + user_id="user-1", + max_budget=None, + ) + + 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.upperbound_key_generate_params", + MagicMock(max_budget=100.0), + ), + patch( + "litellm.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper", + new_callable=AsyncMock, + return_value=MagicMock(), + ), + ): + result = await generate_key_fn( + data=data, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + assert result is not None + + @pytest.mark.asyncio async def test_ghsa_q775_admin_bypasses_budget_ceiling(): """ From e154a363e0d87e019652cb09ba720a59999b166b Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 13 May 2026 20:52:26 -0700 Subject: [PATCH 04/10] fix: capture requested max_budget before any defaults are applied Move _requested_max_budget capture before both default_key_generate_params and upperbound_key_generate_params mutations, so auto-filled values don't trigger the ceiling check for non-admin users. Co-Authored-By: Claude Opus 4.6 --- .../key_management_endpoints.py | 10 ++--- .../test_key_management_endpoints.py | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index c296dd11b3f3..22d0b8b3b024 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -708,6 +708,11 @@ async def _common_key_generation_helper( # noqa: PLR0915 prisma_client=prisma_client, ) + # Capture the caller-supplied max_budget before any defaults or upperbound + # params can fill it, so the ceiling check only fires when the caller + # explicitly requested a budget. + _requested_max_budget = data.max_budget + # check if user set default key/generate params on config.yaml if litellm.default_key_generate_params is not None: for elem in data: @@ -728,11 +733,6 @@ async def _common_key_generation_helper( # noqa: PLR0915 elif key == "metadata" and value == {}: setattr(data, key, litellm.default_key_generate_params.get(key, {})) - # Capture the caller-supplied max_budget *before* upperbound params can - # fill it with a default, so the ceiling check only fires when the caller - # explicitly requested a budget. - _requested_max_budget = data.max_budget - # check if user set upperbound key/generate params on config.yaml _enforce_upperbound_key_params(data, fill_defaults=True) 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 28e40eb86c81..0008a44f095d 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 @@ -10899,6 +10899,46 @@ async def test_ghsa_q775_upperbound_default_not_rejected(): assert result is not None +@pytest.mark.asyncio +async def test_ghsa_q775_default_key_generate_params_not_rejected(): + """ + When default_key_generate_params fills max_budget, the ceiling check must + NOT fire — only caller-supplied budgets trigger it. + """ + data = GenerateKeyRequest() + assert data.max_budget is None + + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-internal", + user_id="user-1", + max_budget=None, + ) + + 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", + {"max_budget": 50.0}, + ), + patch( + "litellm.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper", + new_callable=AsyncMock, + return_value=MagicMock(), + ), + ): + result = await generate_key_fn( + data=data, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + assert result is not None + + @pytest.mark.asyncio async def test_ghsa_q775_admin_bypasses_budget_ceiling(): """ From 62b1c47a0c4eba15ccda8b07d27ef35c0b3e455c Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 13 May 2026 21:00:45 -0700 Subject: [PATCH 05/10] fix: allow unlimited-budget callers to delegate any budget Callers with max_budget=None (unlimited) can legitimately create budget-capped keys. Only block when caller has an explicit budget and the requested budget exceeds it. Co-Authored-By: Claude Opus 4.6 --- .../key_management_endpoints.py | 36 +++++++------------ .../test_key_management_endpoints.py | 29 ++++++++------- 2 files changed, 27 insertions(+), 38 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 22d0b8b3b024..1740aeeb9cac 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -737,33 +737,23 @@ async def _common_key_generation_helper( # noqa: PLR0915 _enforce_upperbound_key_params(data, fill_defaults=True) # Delegated-authority ceiling (GHSA-q775-qw9r-2r4g): a non-admin caller - # cannot grant a key more budget than the caller's own key carries. This - # prevents budget escalation when no upperbound_key_generate_params config - # is present. + # with an explicit budget cannot grant a key a higher budget than their own. + # Callers with max_budget=None (unlimited) can delegate any budget. if ( user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value 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 ): - if user_api_key_dict.max_budget is None: - raise HTTPException( - status_code=400, - detail={ - "error": ( - f"Cannot set max_budget ({_requested_max_budget}) on a generated " - "key because the caller's own key has no budget configured." - ) - }, - ) - if _requested_max_budget > user_api_key_dict.max_budget: - raise HTTPException( - status_code=400, - detail={ - "error": ( - f"max_budget ({_requested_max_budget}) cannot exceed the caller's " - f"own max_budget ({user_api_key_dict.max_budget})." - ) - }, - ) + raise HTTPException( + status_code=400, + detail={ + "error": ( + f"max_budget ({_requested_max_budget}) cannot exceed the caller's " + f"own max_budget ({user_api_key_dict.max_budget})." + ) + }, + ) # APPLY ENTERPRISE KEY MANAGEMENT PARAMS try: 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 0008a44f095d..9faa51f3045c 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 @@ -10758,11 +10758,10 @@ async def test_regenerate_premium_gate_allows_actual_master_key_holder(): @pytest.mark.asyncio -async def test_ghsa_q775_non_admin_no_budget_cannot_set_budget(): +async def test_ghsa_q775_non_admin_unlimited_can_delegate_budget(): """ - Non-admin caller with no max_budget (None) must not be able to set - max_budget on generated keys. Before the fix, the ceiling check was - silently skipped when the caller had no budget configured. + Non-admin caller with max_budget=None (unlimited) can legitimately create + budget-capped keys. Any finite budget is within an unlimited ceiling. """ data = GenerateKeyRequest(max_budget=999999) user_api_key_dict = UserAPIKeyAuth( @@ -10778,18 +10777,18 @@ async def test_ghsa_q775_non_admin_no_budget_cannot_set_budget(): 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.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper", + new_callable=AsyncMock, + return_value=MagicMock(), + ), ): - 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 "no budget configured" in msg.lower() + result = await generate_key_fn( + data=data, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + assert result is not None @pytest.mark.asyncio From c3b33b1ef87e83c400a95eb3d85d6501d371085d Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 13 May 2026 21:23:28 -0700 Subject: [PATCH 06/10] fix: extract audit log helper to fix PLR0915 + sync test error message - Extract inline audit log block from _update_single_user_helper into _schedule_user_update_audit_log to bring statement count below 50 - Update test_validate_max_budget assertion to match current error message ("must be a non-negative finite number" not "cannot be negative") Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../internal_user_endpoints.py | 77 +++++++++++-------- .../test_key_management_endpoints.py | 4 +- 2 files changed, 48 insertions(+), 33 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 1534e74f6b2a..3fb314147192 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1152,6 +1152,44 @@ def _update_internal_user_params( return non_default_values +async def _schedule_user_update_audit_log( + response: Dict[str, Any], + existing_user_row: Optional[BaseModel], + litellm_changed_by: Optional[str], + user_api_key_dict: UserAPIKeyAuth, + litellm_proxy_admin_name: Optional[str], +) -> None: + from litellm.proxy.proxy_server import prisma_client + + try: + updated_user_row = await prisma_client.db.litellm_usertable.find_first( + where={"user_id": response["user_id"]} + ) + if updated_user_row: + user_row_typed = LiteLLM_UserTable( + **updated_user_row.model_dump(exclude_none=True) + ) + asyncio.create_task( + UserManagementEventHooks.create_internal_user_audit_log( + user_id=user_row_typed.user_id, + action="updated", + litellm_changed_by=litellm_changed_by or user_api_key_dict.user_id, + user_api_key_dict=user_api_key_dict, + litellm_proxy_admin_name=litellm_proxy_admin_name, + before_value=( + existing_user_row.model_dump_json(exclude_none=True) + if existing_user_row + else None + ), + after_value=user_row_typed.model_dump_json(exclude_none=True), + ) + ) + except Exception as audit_error: + verbose_proxy_logger.warning( + f"Failed to create audit log for user {response.get('user_id')}: {audit_error}" + ) + + async def _update_single_user_helper( user_request: UpdateUserRequest, user_api_key_dict: UserAPIKeyAuth, @@ -1299,39 +1337,14 @@ async def _update_single_user_helper( data=non_default_values, table_name="user" ) - # Create audit log for successful update if response is not None: - try: - updated_user_row = await prisma_client.db.litellm_usertable.find_first( - where={"user_id": response["user_id"]} - ) - - if updated_user_row: - user_row_typed = LiteLLM_UserTable( - **updated_user_row.model_dump(exclude_none=True) - ) - - # Create audit log asynchronously - asyncio.create_task( - UserManagementEventHooks.create_internal_user_audit_log( - user_id=user_row_typed.user_id, - action="updated", - litellm_changed_by=litellm_changed_by - or user_api_key_dict.user_id, - user_api_key_dict=user_api_key_dict, - litellm_proxy_admin_name=litellm_proxy_admin_name, - before_value=( - existing_user_row.model_dump_json(exclude_none=True) - if existing_user_row - else None - ), - after_value=user_row_typed.model_dump_json(exclude_none=True), - ) - ) - except Exception as audit_error: - verbose_proxy_logger.warning( - f"Failed to create audit log for user {response.get('user_id')}: {audit_error}" - ) + await _schedule_user_update_audit_log( + response=response, + existing_user_row=existing_user_row, + litellm_changed_by=litellm_changed_by, + user_api_key_dict=user_api_key_dict, + litellm_proxy_admin_name=litellm_proxy_admin_name, + ) if response is None: raise HTTPException( 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 9faa51f3045c..32bfc31d8334 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 @@ -5540,7 +5540,9 @@ async def test_validate_max_budget(): _validate_max_budget(-10.0) assert exc_info.value.status_code == 400 - assert "max_budget cannot be negative" in str(exc_info.value.detail) + assert "max_budget must be a non-negative finite number" in str( + exc_info.value.detail + ) @pytest.mark.asyncio From bf9c2799ea2c961894e973b5542256a3d3387bb4 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 13 May 2026 22:07:25 -0700 Subject: [PATCH 07/10] fix: restore closing paren dropped by litellm_internal_staging merge The merge of litellm_internal_staging into this branch dropped the closing `)` from verbose_proxy_logger.warning(...) in _schedule_user_update_audit_log, causing a SyntaxError at import time. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- litellm/proxy/management_endpoints/internal_user_endpoints.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index c69f281ead2a..40b0ec9d6ea9 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1187,7 +1187,9 @@ async def _schedule_user_update_audit_log( except Exception as audit_error: verbose_proxy_logger.warning( f"Failed to create audit log for user {response.get('user_id')}: {audit_error}" - + ) + + def _check_user_update_authz( user_request: UpdateUserRequest, user_api_key_dict: UserAPIKeyAuth, From e66e36904e57ba8e2e871dc011046e624d10bc7a Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 13 May 2026 22:07:42 -0700 Subject: [PATCH 08/10] fix: scope budget guard to self-updates only + add GHSA-wvg4 regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The budget protection in /user/update was blocking ALL non-PROXY_ADMIN users from modifying budget fields, even when updating other users' records (which can_user_call_user_update already restricts). Scoping the guard to self-updates makes the intent clearer and avoids false-positive backwards-incompatibility concerns for admin workflows. Adds three regression tests for GHSA-wvg4-6222-3q4r: - Non-admin self-escalation of max_budget → blocked - Non-admin self-escalation of spend → blocked - Proxy admin updating another user's budget → allowed Co-Authored-By: Claude Opus 4.6 --- .../internal_user_endpoints.py | 21 ++- .../test_internal_user_endpoints.py | 122 ++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 40b0ec9d6ea9..1419c1dbe92c 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1267,16 +1267,29 @@ async def _update_single_user_helper( **existing_user_row.model_dump(exclude_none=True) ) - # Non-admins cannot modify budget-sensitive fields even on their own record - # (GHSA-wvg4-6222-3q4r). - if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value: + # Prevent budget self-escalation (GHSA-wvg4-6222-3q4r): non-admin callers + # must not be able to raise their own budget/spend fields. + # can_user_call_user_update() already restricts non-admins to self-updates, + # so this guard only fires for self-escalation attempts. + _target_user_id = user_request.user_id or ( + getattr(existing_user_row, "user_id", None) + if existing_user_row is not None + else None + ) + _is_self_update = ( + _target_user_id is not None and user_api_key_dict.user_id == _target_user_id + ) + if ( + _is_self_update + and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value + ): _protected_fields = ("max_budget", "soft_budget", "spend") for _field in _protected_fields: if _field in non_default_values: raise HTTPException( status_code=403, detail={ - "error": f"Non-admin users cannot modify '{_field}'. Contact your proxy admin." + "error": f"Non-admin users cannot modify '{_field}' on their own record. Contact your proxy admin." }, ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index f4dc85dad99b..627958cef93c 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -2838,3 +2838,125 @@ def test_enforce_user_info_access_blocks_cross_user_lookup(): assert exc_info.value.status_code == 403 assert "key not allowed to access this user's info" in str(exc_info.value.detail) + + +# --------------------------------------------------------------------------- +# Regression tests for GHSA-wvg4-6222-3q4r: budget self-escalation via +# /user/update +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_ghsa_wvg4_non_admin_cannot_self_escalate_max_budget(mocker): + """Non-admin updating their own record must be blocked from modifying + max_budget (self-escalation).""" + from fastapi import HTTPException + + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + _update_single_user_helper, + ) + + mock_prisma_client = mocker.MagicMock() + existing_user = mocker.MagicMock() + existing_user.model_dump.return_value = { + "user_id": "user-1", + "max_budget": 100, + } + existing_user.user_id = "user-1" + mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock( + return_value=existing_user + ) + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + user_request = UpdateUserRequest( + user_id="user-1", + max_budget=999999, + ) + caller = UserAPIKeyAuth( + user_id="user-1", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + + with pytest.raises(HTTPException) as exc: + await _update_single_user_helper( + user_request=user_request, user_api_key_dict=caller + ) + assert exc.value.status_code == 403 + assert "max_budget" in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_ghsa_wvg4_non_admin_cannot_self_escalate_spend(mocker): + """Non-admin must not be able to reset their own spend to zero.""" + from fastapi import HTTPException + + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + _update_single_user_helper, + ) + + mock_prisma_client = mocker.MagicMock() + existing_user = mocker.MagicMock() + existing_user.model_dump.return_value = { + "user_id": "user-1", + "spend": 50.0, + } + existing_user.user_id = "user-1" + mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock( + return_value=existing_user + ) + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + user_request = UpdateUserRequest( + user_id="user-1", + spend=0, + ) + caller = UserAPIKeyAuth( + user_id="user-1", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + + with pytest.raises(HTTPException) as exc: + await _update_single_user_helper( + user_request=user_request, user_api_key_dict=caller + ) + assert exc.value.status_code == 403 + assert "spend" in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_ghsa_wvg4_proxy_admin_can_update_user_budget(mocker): + """PROXY_ADMIN must still be able to modify another user's budget.""" + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + _update_single_user_helper, + ) + + mock_prisma_client = mocker.MagicMock() + existing_user = mocker.MagicMock() + existing_user.model_dump.return_value = { + "user_id": "target-user", + "max_budget": 100, + } + existing_user.user_id = "target-user" + mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock( + return_value=existing_user + ) + mock_prisma_client.update_data = mocker.AsyncMock( + return_value={"user_id": "target-user", "max_budget": 500} + ) + mock_prisma_client.jsonify_object = mocker.MagicMock(side_effect=lambda x: x) + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin") + + user_request = UpdateUserRequest( + user_id="target-user", + max_budget=500, + ) + admin_caller = UserAPIKeyAuth( + user_id="admin-1", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + result = await _update_single_user_helper( + user_request=user_request, user_api_key_dict=admin_caller + ) + assert result is not None From cc0751a8e8bae795336d2e5aed234997f0ef2fd2 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 13 May 2026 22:15:40 -0700 Subject: [PATCH 09/10] chore: trigger re-review Co-Authored-By: Claude Opus 4.6 From b89f1aebeb01ba6d9834370c3c74fcb33c1f9926 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 13 May 2026 22:23:19 -0700 Subject: [PATCH 10/10] fix: guard prisma_client None in _schedule_user_update_audit_log Satisfies MyPy union-attr check: prisma_client can be None at import time so guard early before accessing .db. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- litellm/proxy/management_endpoints/internal_user_endpoints.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 1419c1dbe92c..75eb5cd55efa 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1161,6 +1161,8 @@ async def _schedule_user_update_audit_log( ) -> None: from litellm.proxy.proxy_server import prisma_client + if prisma_client is None: + return try: updated_user_row = await prisma_client.db.litellm_usertable.find_first( where={"user_id": response["user_id"]}