[Fix] Prevent Internal Users from Creating Invalid Keys#23795
[Fix] Prevent Internal Users from Creating Invalid Keys#23795yuneng-jiang merged 2 commits intolitellm_yj_march_16_2026from
Conversation
…ate and key/update Internal users could exploit key/generate and key/update to create unbound keys (no user_id, no budget) or attach keys to non-existent teams. This adds validation for non-admin callers: auto-assign user_id on generate, reject invalid team_ids, and prevent removing user_id on update. Closes LIT-1884 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR fixes LIT-1884 by closing several security gaps that allowed internal users to create unbound or team-less keys through Key observations:
Confidence Score: 3/5
|
| Filename | Overview |
|---|---|
| litellm/proxy/management_endpoints/key_management_endpoints.py | Core security fix for LIT-1884. Logic issue: the admin bypass for invalid team_id in _validate_update_key_data is dead code because get_team_object raises instead of returning None for non-existent teams, making the team_obj is None guard unreachable. Other changes (auto-assign user_id, key_generation_check non-admin guard) are well-implemented. |
| tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py | 9 new unit tests covering the main fix scenarios. All tests use mocks and no real network calls. Missing a regression test for admin updating a key to a non-existent team_id, which would have caught the dead-code admin bypass. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["key/generate or key/update called"] --> B{"Is PROXY_ADMIN?"}
B -- Yes --> C["Skip user_id auto-assign\nSkip team validation\nProceed normally"]
B -- No --> D{"user_id provided?"}
D -- No --> E["Auto-assign caller user_id"]
D -- Yes --> F{"team_id in request?"}
E --> F
F -- No --> G["Proceed to key_generation_check"]
F -- Yes --> H["call get_team_object"]
H -- Success --> I{"team_obj returned?"}
H -- Raises exception --> J{"Is generate path?"}
J -- "Yes - caught in try/except" --> K["Raise HTTP 400 for non-admin\nAdmin continues with team_table=None"]
J -- "No - update path has no try/except" --> L["404 propagates to ALL callers\nincluding admins - BUG"]
I -- None --> M{"key_generation_check:\nteam_table=None and non-admin?"}
I -- Found --> N["Check team key limits"]
M -- Yes --> O["Raise HTTP 400"]
M -- "No - admin" --> P["Return True"]
N --> G
G --> Q["key_generation_check passes"]
Q --> R["Generate or Update key"]
style L fill:#f96,stroke:#c00
style K fill:#fc6,stroke:#960
Last reviewed commit: 208740a
| if not _is_proxy_admin and data.user_id is None: | ||
| data.user_id = user_api_key_dict.user_id | ||
| verbose_proxy_logger.warning( | ||
| "key/generate: auto-assigning user_id=%s for non-admin caller", | ||
| user_api_key_dict.user_id, | ||
| ) |
There was a problem hiding this comment.
Backwards-incompatible behavior change without a feature flag
Auto-assigning the caller's user_id for every non-admin /key/generate call is a breaking change for existing users who intentionally generate keys without a user_id. Per the codebase convention, behavioral changes that affect existing users should be guarded by a user-controlled flag (e.g., litellm.enforce_user_id_on_key_generate) rather than silently changing behavior for all non-admin callers.
A user relying on the old behavior (generating unbound keys through the API) will silently find their keys now have their user_id attached — changing budget scoping, key list visibility, and any downstream logic that checks key.user_id is None.
Rule Used: What: avoid backwards-incompatible changes without... (source)
| # Validate team exists when non-admin changes team_id (LIT-1884) | ||
| if ( | ||
| data.team_id is not None | ||
| and not _is_proxy_admin | ||
| ): | ||
| try: | ||
| _team_obj = await get_team_object( | ||
| team_id=data.team_id, | ||
| prisma_client=prisma_client, | ||
| user_api_key_cache=user_api_key_cache, | ||
| check_db_only=True, | ||
| ) | ||
| if _team_obj is None: | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail=f"Team not found for team_id={data.team_id}. Non-admin users cannot set keys to non-existent teams.", | ||
| ) | ||
| except HTTPException: | ||
| raise | ||
| except Exception: | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail=f"Team not found for team_id={data.team_id}. Non-admin users cannot set keys to non-existent teams.", | ||
| ) |
There was a problem hiding this comment.
Redundant DB call — result discarded before second fetch
When data.team_id is set and the caller is a non-admin, this new validation block calls get_team_object(..., check_db_only=True) to verify the team exists. Because check_db_only=True bypasses the cache, this goes straight to the database. However, the resolved _team_obj is never reused — a second, identical get_team_object call is made at line 1925 (the existing team-limits check) also with check_db_only=True, hitting the DB again for the same team.
The result from the first call should be threaded forward to replace the second fetch, avoiding the redundant round-trip.
Rule Used: What: Avoid creating new database requests or Rout... (source)
| _is_proxy_admin = ( | ||
| user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value | ||
| ) |
There was a problem hiding this comment.
Inconsistent _is_proxy_admin null guard
The _is_proxy_admin check here omits the user_role is not None guard used in every other occurrence in the same file (e.g., generate_key_fn at line 1229 and key_generation_check at line 357). While None == LitellmUserRoles.PROXY_ADMIN.value evaluates to False in Python, making the behavior identical in practice, the inconsistency is surprising and could mask a future refactor where None might be treated differently.
| _is_proxy_admin = ( | |
| user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value | |
| ) | |
| _is_proxy_admin = ( | |
| user_api_key_dict.user_role is not None | |
| and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value | |
| ) |
Move the non-admin team validation into the existing get_team_object call site to avoid an extra DB round-trip. The existing call already fetches the team for limits checking — we now add the LIT-1884 guard there when team_obj is None for non-admin callers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| # Validate team exists when non-admin sets a new team_id (LIT-1884) | ||
| if team_obj is None and data.team_id is not None and not _is_proxy_admin: | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail=f"Team not found for team_id={data.team_id}. Non-admin users cannot set keys to non-existent teams.", | ||
| ) |
There was a problem hiding this comment.
Admin bypass is unreachable dead code
The guard if team_obj is None at line 1908 is never triggered in practice because get_team_object raises HTTPException(404) when a team doesn't exist, rather than returning None. The companion test test_internal_user_cannot_set_invalid_team_id confirms this explicitly — it mocks get_team_object with side_effect=HTTPException(404), and the test asserts the 404 propagates directly.
This means:
- When
get_team_objectraises at line 1900, execution immediately leaves the block — lines 1908-1912 are never reached. - The
and not _is_proxy_adminguard is therefore never evaluated. - An admin user updating a key to a non-existent
team_idwould also be blocked by the raw 404, contradicting the PR's stated goal that "Admin callers are unaffected."
Compare with the equivalent logic in generate_key_fn around line 1250, where the exception from get_team_object is explicitly caught and only re-raised for non-admins (admins fall through with team_table=None). The same pattern is needed here: wrap the get_team_object call in a try/except and only re-raise for non-admin callers.
There is also no regression test covering the admin-update-with-invalid-team_id scenario; adding one would have surfaced this gap.
c8c4774
into
litellm_yj_march_16_2026
Relevant issues
Closes LIT-1884
Summary
Failure Path (Before Fix)
Internal users with key generation permissions could exploit
/key/generateand/key/updateto:user_idor team), resulting in unbound keys with no budget controlsteam_idvalues on keys (theget_team_objectfailure was silently swallowed)user_id(by setting it to empty string) or change to an invalidteam_idFix
user_idwhen not provided by non-admin callers. Raise 400 whenget_team_object()fails for non-admins instead of swallowing the error. Close the bypass inkey_generation_checkwhereteam_table=Nonewith nokey_generation_settingsreturned True for non-admins.user_id(nullification attempt) with 403 for non-admins. Validateteam_idexists in DB when changed by non-admins.PROXY_ADMIN.Testing
9 unit tests added covering:
key_generation_checknon-admin with no team table → 400key_generation_checkadmin with no team table → allowedAll 9 new tests pass. No regressions in existing 121 key management tests.
Type
🐛 Bug Fix
✅ Test