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.
01Root 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 absentclass 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 guarddef _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.
02Reproduction
Step 1: Enumerate target users (org_admin can call /user/list)
GET /user/list HTTP/1.1
Authorization: Bearer sk-k8UiO_Z0QOIbhjpQTFxR2QHTTP 200: returns all users and their current roles
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.
03Impact
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.
04CVSS 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
05Recommended 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).