Skip to content

[Fix] Privilege Escalation on /key/block, /key/unblock, and /key/update max_budget#23781

Merged
yuneng-jiang merged 2 commits intolitellm_yj_march_16_2026from
litellm_key_admin_privilege_escalation_fix
Mar 16, 2026
Merged

[Fix] Privilege Escalation on /key/block, /key/unblock, and /key/update max_budget#23781
yuneng-jiang merged 2 commits intolitellm_yj_march_16_2026from
litellm_key_admin_privilege_escalation_fix

Conversation

@yuneng-jiang
Copy link
Copy Markdown
Contributor

Relevant issues

Reported via security disclosure email.

Summary

Failure Path (Before Fix)

Any virtual key tied to a non-admin user (INTERNAL_USER role) could call /key/block, /key/unblock, and /key/update on arbitrary keys. This allowed:

  • Blocking/unblocking another user's key
  • Raising their own key budget via /key/update without admin privileges

Root cause: /key/block and /key/unblock are included in key_management_routes, which is part of internal_user_routes. The route-level check passes for INTERNAL_USER, and neither handler enforced an admin or ownership check. For /key/update, max_budget was not restricted to admin-only callers.

Fix

Added _check_key_admin_access() helper that verifies the caller is a proxy admin, team admin (for the target key's team), or org admin (for the team's org). This check is now enforced in:

  1. block_key — before any DB mutation
  2. unblock_key — before any DB mutation
  3. update_key_fn — when max_budget is being changed

Non-admin users can still update non-budget fields on their own keys.

Testing

6 new unit tests:

  • test_block_key_rejected_for_internal_user
  • test_unblock_key_rejected_for_internal_user
  • test_block_key_allowed_for_proxy_admin
  • test_block_key_allowed_for_team_admin
  • test_update_key_max_budget_rejected_for_internal_user
  • test_update_key_non_budget_fields_allowed_for_internal_user

Type

🐛 Bug Fix
✅ Test

…x_budget updates to admins

Non-admin users (INTERNAL_USER) could call /key/block and /key/unblock on
arbitrary keys, and modify max_budget on their own keys via /key/update.
These endpoints are now restricted to proxy admins, team admins, or org admins.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 16, 2026 10:36pm

Request Review

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 16, 2026

Greptile Summary

This PR fixes a privilege-escalation vulnerability where any INTERNAL_USER-role virtual key could call /key/block, /key/unblock, and /key/update (to raise max_budget) on arbitrary keys without any ownership or admin check.

The fix introduces a shared _check_key_admin_access helper that enforces three allowed caller roles — proxy admin, team admin for the key's team, or org admin for the team's organization — and wires it into block_key, unblock_key, and _validate_update_key_data (for max_budget changes). Non-budget fields in /key/update are unaffected, preserving the existing experience for non-admin users updating their own keys.

Key observations:

  • The _check_key_admin_access helper silently falls through to a generic 403 when the target key has a team_id but get_team_object returns None (orphaned team), giving legitimate team admins a confusing error with no indication the team is missing.
  • The 6 new tests cover rejection for INTERNAL_USER and the allow-path for proxy admin / team admin on block_key, but leave unblock_key allowed-paths (proxy admin, team admin) and the entire org-admin branch of _check_key_admin_access untested.
  • Several issues flagged in earlier review rounds (redundant DB fetch inside the helper, check_db_only=True cache bypass, and the silent privilege skip when prisma_client is None in _validate_update_key_data) remain open and are noted in previous threads.

Confidence Score: 3/5

  • The security fix is directionally correct but the implementation has an edge-case behavioral issue and notable test coverage gaps that should be addressed before merging.
  • The core privilege-escalation fix is sound: proxy admins pass immediately, team/org admins are verified against the key's team, and all other callers are denied. However, the helper silently maps a missing-team scenario to a 403 (instead of 404), which can confuse legitimate team admins. Additionally, the unblock allowed-paths and the entire org-admin allow-path have no test coverage, leaving security-critical branches unverified. Prior review threads (redundant DB fetch, prisma_client is None bypass, cache skipped via check_db_only=True) also remain unresolved.
  • litellm/proxy/management_endpoints/key_management_endpoints.py (the _check_key_admin_access helper, specifically the orphaned-team fall-through) and tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py (missing unblock allowed-case tests and org-admin coverage).

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/key_management_endpoints.py Adds _check_key_admin_access helper and enforces it in block_key, unblock_key, and _validate_update_key_data; the helper has a silent fall-through to 403 when the key's team no longer exists, which can mislead legitimate team admins.
tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py Adds 6 unit tests for the new admin access checks; missing coverage for unblock_key allowed paths (proxy admin, team admin) and the org admin branch of _check_key_admin_access.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Request: block_key / unblock_key / update_key max_budget] --> B[Compute hashed_token]
    B --> C[_check_key_admin_access]
    C --> D{Is PROXY_ADMIN?}
    D -- Yes --> E[Allow - return]
    D -- No --> F[Fetch key row from DB]
    F --> G{Key found?}
    G -- No --> H[Raise 404]
    G -- Yes --> I{Has team_id?}
    I -- No --> J[Raise 403]
    I -- Yes --> K[get_team_object]
    K --> L{team_obj is None?}
    L -- Yes --> J
    L -- No --> M{_is_user_team_admin?}
    M -- Yes --> E
    M -- No --> N{_is_user_org_admin_for_team?}
    N -- Yes --> E
    N -- No --> J[Raise 403]
    E --> O[Proceed with DB mutation]
Loading

Last reviewed commit: 55c7ba9

Comment on lines +4783 to +4788
team_obj = await get_team_object(
team_id=target_key_row.team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
check_db_only=True,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check_db_only=True bypasses cache for team lookup

Passing check_db_only=True forces a DB hit every time this function runs, even when the team object is already warm in user_api_key_cache. For the block/unblock path this means at minimum two sequential DB queries per request (key lookup + team lookup).

Consider dropping check_db_only=True to allow the cache to serve the team object when available — the same pattern used elsewhere in the file for management-endpoint team lookups.

Comment on lines +1841 to +1850
if data.max_budget is not None and data.max_budget != existing_key_row.max_budget:
if prisma_client is not None:
hashed_key = existing_key_row.token
await _check_key_admin_access(
user_api_key_dict=user_api_key_dict,
hashed_token=hashed_key,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
route="/key/update (max_budget)",
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Admin check silently skipped when prisma_client is None

The if prisma_client is not None: guard means that if the DB is somehow unavailable at this call site, the max_budget privilege check is bypassed entirely and a non-admin user can silently raise their own budget.

While update_key_fn raises earlier when prisma_client is None, _validate_update_key_data is a reusable helper that could be called from other call sites without that guarantee. The safer approach is to fail loudly rather than silently permit the mutation — for example, raising an HTTPException(status_code=500) when prisma_client is None and a budget change is requested, so the absence of DB connectivity is an explicit error rather than a hidden privilege bypass.

Comment on lines +4771 to +4774
# Look up the target key to find its team
target_key_row = await prisma_client.db.litellm_verificationtoken.find_unique(
where={"token": hashed_token}
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant DB fetch in the /key/update max_budget path

When _check_key_admin_access is called from _validate_update_key_data, the target key was already fetched as existing_key_row (which carries team_id). This find_unique call adds a redundant DB round-trip on every max_budget update request.

A simple improvement would be to add an optional team_id parameter to _check_key_admin_access that callers can provide when the team is already known, skipping this lookup. For _validate_update_key_data, you could pass getattr(existing_key_row, "team_id", None) directly.

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
@yuneng-jiang yuneng-jiang merged commit 2d98b49 into litellm_yj_march_16_2026 Mar 16, 2026
5 of 46 checks passed
Comment on lines +7319 to +7334
user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
api_key="sk-admin",
user_id="admin_user",
)

result = await block_key(
data=BlockKeyRequest(key="sk-test123456789"),
http_request=mock_request,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
assert result is not None


@pytest.mark.asyncio
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing unblock_key allowed-case tests

The PR tests unblock_key being rejected for internal users, but there are no counterpart tests verifying that the unblock operation succeeds for proxy admins or team admins. Since the security fix is symmetric across block and unblock, the gap leaves the unblock allow-path untested in this suite.

By contrast, block_key has both test_block_key_allowed_for_proxy_admin and test_block_key_allowed_for_team_admin. Consider adding equivalent tests for unblock_key:

@pytest.mark.asyncio
async def test_unblock_key_allowed_for_proxy_admin(monkeypatch):
    ...

@pytest.mark.asyncio
async def test_unblock_key_allowed_for_team_admin(monkeypatch):
    ...

Comment on lines +7395 to +7410
mock_existing_key.project_id = None
mock_existing_key.max_budget = 10.0
mock_existing_key.models = []
mock_existing_key.model_dump.return_value = {
"token": test_hashed_token,
"user_id": "internal_user",
"team_id": None,
"max_budget": 10.0,
}

mock_prisma_client.get_data = AsyncMock(return_value=mock_existing_key)
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(
return_value=mock_existing_key
)

monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Org admin path in _check_key_admin_access has no test coverage

_check_key_admin_access contains three allow-paths: proxy admin, team admin, and org admin (via _is_user_org_admin_for_team). The six new tests cover the first two paths but there is no test exercising the org admin branch. If _is_user_org_admin_for_team has a defect, org admins would be silently blocked (or—depending on the defect—incorrectly allowed), and neither case would be caught by the new test suite.

Consider adding:

@pytest.mark.asyncio
async def test_block_key_allowed_for_org_admin(monkeypatch):
    """Org admins should be able to block keys belonging to their org's teams."""
    ...

Comment on lines +4780 to +4796
# If the key belongs to a team, check team admin / org admin
if target_key_row.team_id:
team_obj = await get_team_object(
team_id=target_key_row.team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
check_db_only=True,
)
if team_obj is not None:
if _is_user_team_admin(
user_api_key_dict=user_api_key_dict, team_obj=team_obj
):
return
if await _is_user_org_admin_for_team(
user_api_key_dict=user_api_key_dict, team_obj=team_obj
):
return
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Team admin silently denied when team lookup returns None

When the target key has a team_id but get_team_object returns None (e.g., the team record was deleted after the key was created), the entire if team_obj is not None: block is skipped and execution falls through to the unconditional raise HTTPException(403). This means a legitimate team admin operating on an orphaned-team key receives a confusing 403 with no indication that the team itself is missing.

The safer approach is to check team_obj is None explicitly inside the if target_key_row.team_id: branch and raise a 404 for a missing team rather than silently falling through to the generic authorization error. This makes the failure mode observable and distinguishable from a genuine authorization denial.

@ishaan-berri ishaan-berri deleted the litellm_key_admin_privilege_escalation_fix branch March 26, 2026 22:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant