Skip to content

fix(proxy): admin-gate allowed_routes presence on /key/update and /key/regenerate (LIT-4139) - #31987

Merged
yucheng-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit_4139_allowed_routes_gate
Jul 3, 2026
Merged

fix(proxy): admin-gate allowed_routes presence on /key/update and /key/regenerate (LIT-4139)#31987
yucheng-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit_4139_allowed_routes_gate

Conversation

@yucheng-berri

@yucheng-berri yucheng-berri commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Resolves LIT-4139

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Type

Bug Fix

Changes

_check_allowed_routes_caller_permission previously used truthiness (if not allowed_routes: return) to distinguish "field not set" from "field explicitly set to a truthy value". The refactor adds an allowed_routes_was_provided: bool = False keyword param. Raw-body call sites pass "allowed_routes" in data.model_fields_set; the omit-default carve-out remains for not allowed_routes_was_provided and not allowed_routes

Four raw-body call sites updated to pass the presence flag from model_fields_set: _common_key_generation_helper (line 1571), generate_service_account_key_fn (line 1742), _validate_update_key_data (line 2237), and regenerate_key_fn (line 4559). Two derived-value call sites left unchanged (the post-handle_key_type recheck with allow_safe_presets=True at line 914 and its regenerate mirror at line 4580) — those pass values derived by handle_key_type from data.key_type, not from data.model_fields_set, and are handled by the _NON_ADMIN_SAFE_ALLOWED_ROUTES_PRESETS allowlist inside the helper

In regenerate_key_fn the gate runs before the premium_user license check so the rejection is consistent across premium and non-premium deployments. That ordering is pinned by test_non_admin_regenerate_key_allowed_routes_rejected_before_enterprise_gate

Eight new tests in TestAllowedRoutesCallerPermission in tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py. Five cover the non-admin explicit-empty case on generate / update / regenerate, explicit-null on update, and the enterprise-gate ordering on regenerate. Three helper-level tests pin the derived-value acceptance path (llm_api_routes accepted, management_routes rejected) and the load-bearing and allowed_routes guard against a future caller that pairs allowed_routes_was_provided=True with a None value. Every added attack-vector or contract test is mutation-killed against a targeted change to the code it locks. Full mapped file (347 tests) green

Screenshots / Proof of Fix

Live proxy on localhost:4010 against Postgres, LITELLM_LICENSE set so /key/regenerate is available. Admin creates alice as an internal_user and sets allowed_routes: ["/chat/completions"] on alice's personal key

/key/update explicit empty

$ curl -sS -X POST http://localhost:4010/key/update \
    -H "Authorization: Bearer $ALICE_KEY" -H "Content-Type: application/json" \
    -d "{\"key\":\"$ALICE_OWN\",\"allowed_routes\":[]}"
{"error":{"message":"{'error': 'Only proxy admins can set `allowed_routes` on a key. Use `key_type` to pick a preset route bucket instead.'}","code":"403"}}

/key/update explicit null

$ curl -sS -X POST http://localhost:4010/key/update \
    -H "Authorization: Bearer $ALICE_KEY" -H "Content-Type: application/json" \
    -d "{\"key\":\"$ALICE_OWN\",\"allowed_routes\":null}"
{"error":{"message":"{'error': 'Only proxy admins can set `allowed_routes` on a key. Use `key_type` to pick a preset route bucket instead.'}","code":"403"}}

/key/regenerate explicit empty

$ curl -sS -X POST http://localhost:4010/key/regenerate \
    -H "Authorization: Bearer $ALICE_KEY" -H "Content-Type: application/json" \
    -d "{\"key\":\"$ALICE_OWN\",\"allowed_routes\":[]}"
{"error":{"message":"{'error': 'Only proxy admins can set `allowed_routes` on a key. Use `key_type` to pick a preset route bucket instead.'}","code":"403"}}

DB state after three rejected attacks

$ curl -sS "http://localhost:4010/key/info?key=$ALICE_OWN" -H "Authorization: Bearer sk-1234"
allowed_routes: ['/chat/completions']

The admin-set value survived. Controls: admin still writes allowed_routes freely, admin still clears with [], alice still updates unrelated fields (tpm_limit=42) on her key, alice's /key/generate {"key_type":"llm_api"} still resolves to allowed_routes: ["llm_api_routes"] through the preset flow, alice's /key/generate {} still mints keys with the default allowed_routes: []

Boundary conditions

Same fixed premium proxy, verified after the internal review

Explicit null on generate and regenerate as a non-admin. The presence check keys on model_fields_set so null is caught the same as [] or any other explicit value

$ curl -sS -X POST http://localhost:4010/key/generate \
    -H "Authorization: Bearer $ALICE_KEY" -H "Content-Type: application/json" \
    -d '{"allowed_routes":null}'
{"error":{"message":"{'error': 'Only proxy admins can set `allowed_routes` on a key. Use `key_type` to pick a preset route bucket instead.'}","code":"403"}}
$ curl -sS -X POST http://localhost:4010/key/regenerate \
    -H "Authorization: Bearer $ALICE_KEY" -H "Content-Type: application/json" \
    -d "{\"key\":\"$ALICE_OWN\",\"allowed_routes\":null}"
{"error":{"message":"{'error': 'Only proxy admins can set `allowed_routes` on a key. Use `key_type` to pick a preset route bucket instead.'}","code":"403"}}

Raw-body ["llm_api_routes"] from a non-admin on all three endpoints. Raw-body call sites leave allow_safe_presets=False, so a non-admin cannot hand-invoke the safe-preset carve-out; only the key_type preset flow reaches the derivation call site with allow_safe_presets=True

$ curl -sS -X POST http://localhost:4010/key/generate \
    -H "Authorization: Bearer $ALICE_KEY" -H "Content-Type: application/json" \
    -d '{"allowed_routes":["llm_api_routes"]}'
{"error":{"message":"{'error': 'Only proxy admins can set `allowed_routes` on a key. Use `key_type` to pick a preset route bucket instead.'}","code":"403"}}

/key/update and /key/regenerate return the same 403 for the same payload

Admin [] accepted on all three endpoints (admin fast path)

$ curl -sS -X POST http://localhost:4010/key/generate \
    -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
    -d '{"allowed_routes":[]}'
minted key ends: PGCsEw / allowed_routes: []
$ curl -sS -X POST http://localhost:4010/key/update \
    -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
    -d "{\"key\":\"$ALICE_OWN\",\"allowed_routes\":[]}"
allowed_routes: []
$ curl -sS -X POST http://localhost:4010/key/regenerate \
    -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
    -d "{\"key\":\"$ALICE_OWN\",\"allowed_routes\":[]}"
new key ends: 9MIulg / allowed_routes: []

Note on admin null (pre-existing, unrelated to this fix): UpdateKeyRequest.allowed_routes and RegenerateKeyRequest.allowed_routes are typed Optional[list] = [] but the Pydantic validator on these routes rejects an explicit null at request-parse time (400 on /key/update, 500 on /key/regenerate after the wrapper). /key/generate accepts and coerces to []. Neither behavior changed here. The unit tests for the helper-level None case bypass Pydantic parsing (constructing the model in Python directly), so they exercise the hypothetical future where Pydantic loosens and confirm the gate still behaves

Out of scope, tracked separately

  • LIT-4137 covers the same presence-based check for the bulk endpoints (/key/bulk_update, /team/key/bulk_update), which route through _process_single_key_update and do not invoke this helper. KeyUpdateFields (extra="forbid") omits allowed_routes, so the field is currently unreachable through the team-bulk path; single-key bulk is admin-only. Latent trap if the allowlist widens

Note

Medium Risk
Tightens authorization on proxy key management endpoints; behavior change for non-admins who explicitly send allowed_routes, with broad test coverage to limit regressions on the key_type preset flow.

Overview
Non-admins could bypass the allowed_routes admin gate by sending [] or null in /key/generate, /key/update, and /key/regenerate, because _check_allowed_routes_caller_permission treated empty/falsy values as “not set.”

The helper now takes allowed_routes_was_provided, set from "allowed_routes" in data.model_fields_set at raw-body entry points, so any explicit body value (including empty list or null) requires PROXY_ADMIN. Omitted fields still skip the gate when the value is the model default. The key_type preset path is unchanged: post-handle_key_type rechecks use allow_safe_presets=True without the presence flag.

regenerate_key_fn runs this check before the enterprise premium_user gate so non-admins get a consistent 403. Tests cover explicit empty/null, preset allowlist behavior, and the safe-preset None guard.

Reviewed by Cursor Bugbot for commit 067c20a. Bugbot is set up for automated code reviews on this repo. Configure here.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Closes a proxy key authorization bypass where non-admins could send explicit allowed_routes: [] or null on key create/update/regenerate without triggering the admin gate, because the helper previously returned early on any falsy value regardless of whether the field was in the request body.

  • _check_allowed_routes_caller_permission gains an allowed_routes_was_provided: bool = False kwarg; the early-return guard changes from not allowed_routes to not allowed_routes_was_provided and not allowed_routes, so an explicit empty list or null in the body no longer slips through.
  • The four raw-body call sites (generate_key_fn, generate_service_account_key_fn, _validate_update_key_data, regenerate_key_fn) now pass allowed_routes_was_provided=\"allowed_routes\" in data.model_fields_set; the two post-handle_key_type rechecks are intentionally left without the flag since their values are derived, not from the request body.
  • Eight new unit tests cover explicit-empty and explicit-null across all affected endpoints, the enterprise-gate ordering on regenerate, safe-vs-unsafe preset discrimination, and the None+allowed_routes_was_provided=True TypeError guard.

Confidence Score: 5/5

Safe to merge — the change is a minimal, targeted tightening of a single helper and its four call sites, with no behaviour change for well-formed requests that omit the field.

The helper logic is straightforward, the four updated call sites are consistent, the two intentionally unchanged post-handle_key_type rechecks are correctly reasoned about in both the code comments and the PR description, and the eight new tests are mutation-killed against the exact lines they guard.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/key_management_endpoints.py Adds allowed_routes_was_provided: bool = False kwarg to _check_allowed_routes_caller_permission and passes the presence flag at the four raw-body call sites; the early-return guard is tightened from not allowed_routes to not allowed_routes_was_provided and not allowed_routes, correctly closing the bypass for explicit-empty/null values; the allow_safe_presets branch gets an and allowed_routes null-guard to prevent TypeError.
tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py Eight new tests added to TestAllowedRoutesCallerPermission: explicit-empty and explicit-null on update, explicit-empty on regenerate and generate, enterprise-gate ordering on regenerate, helper acceptance of safe presets, helper rejection of unsafe presets, and TypeError guard for allowed_routes_was_provided=True + None; no existing tests are weakened.

Reviews (4): Last reviewed commit: "fix(proxy): admin-gate `allowed_routes` ..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR tightens the allowed_routes admin gate on /key/update and /key/regenerate by introducing an is_explicit flag to _check_allowed_routes_caller_permission, so that an attacker sending allowed_routes: [] or allowed_routes: null in the request body is blocked rather than silently bypassed by the old truthy guard (if not allowed_routes: return).

  • _check_allowed_routes_caller_permission gains is_explicit: bool = False; raw-body call sites now pass "allowed_routes" in data.model_fields_set so a field that is present-but-empty is distinguishable from a field that was simply omitted; the two post-handle_key_type call sites are intentionally left unchanged.
  • The regenerate_key_fn permission check is moved before the premium_user enterprise gate so non-admin callers get a consistent 403 regardless of license state; five new unit tests (explicit-empty and explicit-null on update, explicit-empty on generate and regenerate, enterprise-gate ordering on regenerate) verify the corrected paths.
  • generate_service_account_key_fn is the one changed call site without a new explicit-presence test; coverage there mirrors what the other four sites now have.

Confidence Score: 4/5

The changes are narrowly scoped to the permission helper and its four call sites; the logic correctly threads model_fields_set presence through without touching the safe-presets path used by key_type flows.

The core fix is well-constructed and the five new tests cover the critical newly-handled cases. The only gap is that generate_service_account_key_fn has no new explicit-presence test; a regression there would go undetected by this test suite.

tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py needs a test for the explicit-presence case on generate_service_account_key_fn to match coverage on the other three endpoints.

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/key_management_endpoints.py Adds is_explicit: bool param to _check_allowed_routes_caller_permission and threads "allowed_routes" in data.model_fields_set through all four raw-body call sites; logic is correct and backward-compatible with the two post-handle_key_type call sites that keep defaults.
tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py Adds five focused tests for explicit-empty and explicit-null allowed_routes on generate/update/regenerate; generate_service_account_key_fn is the one changed call site without a new explicit-presence test.

Reviews (2): Last reviewed commit: "fix(proxy): admin-gate `allowed_routes` ..." | Re-trigger Greptile

@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@yucheng-berri
yucheng-berri force-pushed the litellm_lit_4139_allowed_routes_gate branch from 4f3ba8d to 96991c0 Compare July 2, 2026 20:39
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai re-review, amended commit (96991c0) adds three helper-level tests addressing the internal reviewer's Minor + Nit: (1) positive safe-preset accept test (test_helper_accepts_derived_safe_preset_for_non_admin), mutation-killed against removing llm_api_routes from the frozenset; (2) negative safe-preset reject test (test_helper_rejects_derived_unsafe_preset_for_non_admin), mutation-killed against adding management_routes to the frozenset; (3) None-guard test (test_helper_rejects_when_is_explicit_and_none_without_typeerror), mutation-killed against removing the and allowed_routes guard on the safe-preset branch.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 96991c0. Configure here.

…key/regenerate (LIT-4139)

`_check_allowed_routes_caller_permission` previously keyed its
admin-only rule on truthiness. The refactor adds an `allowed_routes_was_provided`
keyword param that raw-body call sites populate from
`"allowed_routes" in data.model_fields_set`, so a caller that omits
the field (default flows through) is distinct from one that sends
any explicit value.

Four raw-body call sites now pass `allowed_routes_was_provided=...`:
`_common_key_generation_helper`, `generate_service_account_key_fn`,
`_validate_update_key_data`, and `regenerate_key_fn`.

Two derived-value call sites keep the pre-fix shape: the
post-`handle_key_type` recheck at `_common_key_generation_helper`
and the mirror in `regenerate_key_fn`. Both pass values produced by
`handle_key_type` (not by the request body), so `allowed_routes_was_provided` stays
False and the `allow_safe_presets=True` carve-out continues to accept
the `llm_api_routes` / `info_routes` presets.

In `regenerate_key_fn` the gate runs before the `premium_user`
license check, matching the LIT-4092 ordering.
`test_non_admin_regenerate_key_allowed_routes_rejected_before_enterprise_gate`
pins the ordering; it fails on a swap of the two gates.

Tests in `tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py`
under `TestAllowedRoutesCallerPermission`:

- test_non_admin_generate_key_explicit_empty_allowed_routes_rejected
- test_non_admin_update_key_explicit_empty_allowed_routes_rejected
- test_non_admin_update_key_explicit_null_allowed_routes_rejected
- test_non_admin_regenerate_key_explicit_empty_allowed_routes_rejected
- test_non_admin_regenerate_key_allowed_routes_rejected_before_enterprise_gate
- test_helper_accepts_derived_safe_preset_for_non_admin
- test_helper_rejects_derived_unsafe_preset_for_non_admin
- test_helper_rejects_when_provided_and_none_without_typeerror

The four attack-vector tests fail on the pre-fix HEAD and pass on this
commit. Three helper-level tests pin the derived-value branch and the
load-bearing None guard; each is mutation-killed against a targeted
change to the frozenset or the guard. Full mapped test file (347 tests)
green.
@yucheng-berri
yucheng-berri force-pushed the litellm_lit_4139_allowed_routes_gate branch from 96991c0 to 067c20a Compare July 2, 2026 22:20
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai re-review, amended commit (067c20a) renames the is_explicit kwarg to allowed_routes_was_provided per internal reviewer feedback. Zero logic change; the name now reads self-documenting at each of the four call sites (allowed_routes_was_provided="allowed_routes" in data.model_fields_set). All 12 tests in TestAllowedRoutesCallerPermission still pass, including the three mutation-killed helper-level tests.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 067c20a. Configure here.

@mateo-berri mateo-berri left a comment

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.

LGTM; thanks!

@yucheng-berri
yucheng-berri merged commit b426925 into litellm_internal_staging Jul 3, 2026
127 checks passed
@yucheng-berri
yucheng-berri deleted the litellm_lit_4139_allowed_routes_gate branch July 3, 2026 05:09
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.

2 participants