Skip to content

fix(guardrails): make guardrails see the authenticated identity on every path - #43754

Open
caduri wants to merge 5 commits into
BerriAI:mainfrom
caduri:bugfix/guardrail-apply-endpoint-authenticated-identity
Open

caduri wants to merge 5 commits into
BerriAI:mainfrom
caduri:bugfix/guardrail-apply-endpoint-authenticated-identity

Conversation

@caduri

@caduri caduri commented Sep 29, 2026 •

Copy link
Copy Markdown

TLDR

Problem this solves:

  • Callers could forge key alias, team, hash and route for guardrails
  • It hit /guardrails/apply_guardrail and pass-through routes
  • CLI session keys leaked their raw per-login token to guardrail vendors
  • Guardrail metadata carried key callback secrets, JWT claims and proxy config

How it solves it:

  • Strip caller-supplied identity fields with the chat path's own strip
  • Overlay the authenticated key's identity on every guardrail path
  • Give guardrails and failure logs the proxy's cleaned real headers
  • Send the key's stable logged ID, never a raw session token
  • Build guardrail key metadata from an allowlist of identity fields
  • Give realtime transcript guardrails that same identity allowlist

Intentional product change: guardrail vendors now always receive the authenticated identity in request metadata, even when the body sent none, and pass-through guardrails no longer see any identity or headers a request body claims

User Flow

Before: a key holder can make the guardrail vendor believe the request came from another key or team

  1. The proxy admin sets up a generic guardrail API with per-key and per-team vendor policies
  2. A team-prod key holder sends POST https://litellm-domain/guardrails/apply_guardrail, or pass-through /my-llm, with a forged alias and team in metadata
  3. The vendor sees alias batch-worker on both routes, plus team team-exempt on apply_guardrail, and applies that policy
  4. Any key holder can claim another key's guardrail treatment, and on apply_guardrail another team's too

After: the vendor always receives the caller's real identity

  1. The proxy admin sets up the same generic guardrail API
  2. The same key holder sends the same requests with the same forged metadata
  3. The vendor sees the key's real alias and team team-prod, and the upstream provider gets an unchanged body
  4. No key holder can claim another key's or team's guardrail treatment

Relevant issues

Split out of #37055, as requested in its review

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
    • The regression tests fail at the merge base 2c9b0e0 with assertion errors
    • Each of these mutations fails a test: leaking an extra metadata key, keeping caller headers, dropping the key hash, dropping the header re-attach, skipping credential redaction, keeping the custom key header or MCP credential headers, and handing realtime Gray Swan the full key dump
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/unit/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)
    • Greptile scored 5/5 on 4b63b53, and Veria reported no security issues on the same commit. A re-review of af6c230 after the rebase is requested
    • Bugbot has not run on this PR

Delays in PR merge?

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

Screenshots / Proof of Fix

All runs are against a live proxy on localhost:4000 started with python litellm/proxy/proxy_cli.py --config proof_config.yaml --detailed_debug --use_v2_migration_resolver, backed by a real Postgres, and every completion is a real, billed call to Bedrock us.anthropic.claude-haiku-4-5-20251001-v1:0. The guardrail vendor is a small FastAPI inspector app on 127.0.0.1:8787 that implements POST /beta/litellm_basic_guardrail_api, answers {"action": "NONE"}, and appends every payload it receives to inspector.jsonl. Nothing else is stubbed

/my-llm is a user-defined pass-through route whose upstream is an OpenAI-compatible endpoint: the proxy's own /v1/chat/completions, which calls Bedrock. That is what puts a real model call behind the pass-through cases

model_list:
  - model_name: bedrock-haiku-4-5
    litellm_params:
      model: bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0
      aws_region_name: us-east-1

guardrails:
  - guardrail_name: gg-inspector
    litellm_params:
      guardrail: generic_guardrail_api
      mode: pre_call
      api_base: http://127.0.0.1:8787
      default_on: false
      extra_headers: ["x-tenant"]

general_settings:
  master_key: os.environ/LITELLM_MASTER_KEY
  database_url: os.environ/DATABASE_URL
  pass_through_endpoints:
    - path: /my-llm
      target: http://127.0.0.1:4000/v1/chat/completions
      headers:
        Authorization: os.environ/PROOF_UPSTREAM_AUTHORIZATION
      guardrails:
        gg-inspector:

The keys come from the real management API, once, and both runs use the same database. /guardrails/apply_guardrail is admin-only by default, so the apply case uses a key granted that route

$ curl -s -X POST http://localhost:4000/team/new -H "Authorization: Bearer $LITELLM_MASTER_KEY" -d '{"team_id": "team-prod", "team_alias": "team-prod"}'
$ curl -s -X POST http://localhost:4000/key/generate -H "Authorization: Bearer $LITELLM_MASTER_KEY" -d '{"key_alias": "prod-app", "team_id": "team-prod", "models": ["bedrock-haiku-4-5"]}'            # PROOF_KEY
$ curl -s -X POST http://localhost:4000/key/generate -H "Authorization: Bearer $LITELLM_MASTER_KEY" -d '{"key_alias": "guardrail-client", "team_id": "team-prod", "allowed_routes": ["/guardrails/apply_guardrail"]}'   # APPLY_KEY

Before (02f61c9)

Case 1: /guardrails/apply_guardrail with a forged identity in metadata

  1. Run
    $ curl -s -X POST http://localhost:4000/guardrails/apply_guardrail -H "Authorization: Bearer $APPLY_KEY" -H 'Content-Type: application/json' -d '{"guardrail_name": "gg-inspector", "text": "hello", "metadata": {"user_api_key_alias": "batch-worker", "user_api_key_team_id": "team-exempt"}}'
    
  2. Observe
    {"response_text":"hello"}
    
  3. Run
    $ tail -1 inspector.jsonl | jq -c '.request_data | {user_api_key_alias, user_api_key_team_id}'
    
  4. Observe
    {"user_api_key_alias":"batch-worker","user_api_key_team_id":"team-exempt"}
    

Case 2: pass-through route with a forged identity in the body, real Bedrock call upstream

  1. Run
    $ curl -s -X POST http://localhost:4000/my-llm -H "Authorization: Bearer $PROOF_KEY" -H 'Content-Type: application/json' -d '{"model": "bedrock-haiku-4-5", "messages": [{"role": "user", "content": "Reply with exactly: identity check"}], "max_tokens": 10, "metadata": {"user_api_key_alias": "batch-worker", "user_api_key_team_id": "team-exempt"}}' | jq -c '{content: .choices[0].message.content, usage}'
    
  2. Observe
    {"content":"identity check","usage":{"completion_tokens":5,"prompt_tokens":13,"total_tokens":18,"completion_tokens_details":{"reasoning_tokens":0,"text_tokens":5},"prompt_tokens_details":{"cached_tokens":0,"text_tokens":13,"cache_write_tokens":0,"cache_creation_tokens":0},"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}
    
  3. Run
    $ tail -1 inspector.jsonl | jq -c '.request_data | {user_api_key_alias, user_api_key_team_id}'
    
  4. Observe
    {"user_api_key_alias":"batch-worker","user_api_key_team_id":"team-prod"}
    

Case 3: pass-through route with a real x-tenant header and a forged one in the body (extra_headers: [x-tenant])

  1. Run
    $ curl -s -X POST http://localhost:4000/my-llm -H "Authorization: Bearer $PROOF_KEY" -H 'Content-Type: application/json' -H 'x-tenant: tenant-real' -d '{"model": "bedrock-haiku-4-5", "messages": [{"role": "user", "content": "Reply with exactly: header check"}], "max_tokens": 10, "headers": {"x-tenant": "tenant-forged"}}' | jq -c '{content: .choices[0].message.content, usage}'
    
  2. Observe
    {"content":"header check","usage":{"completion_tokens":5,"prompt_tokens":13,"total_tokens":18,"completion_tokens_details":{"reasoning_tokens":0,"text_tokens":5},"prompt_tokens_details":{"cached_tokens":0,"text_tokens":13,"cache_write_tokens":0,"cache_creation_tokens":0},"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}
    
  3. Run
    $ tail -1 inspector.jsonl | jq -c '.request_headers'
    
  4. Observe
    {"x-tenant":"tenant-forged"}
    

After (af6c230)

Case 1: /guardrails/apply_guardrail with a forged identity in metadata

  1. Run
    $ curl -s -X POST http://localhost:4000/guardrails/apply_guardrail -H "Authorization: Bearer $APPLY_KEY" -H 'Content-Type: application/json' -d '{"guardrail_name": "gg-inspector", "text": "hello", "metadata": {"user_api_key_alias": "batch-worker", "user_api_key_team_id": "team-exempt"}}'
    
  2. Observe
    {"response_text":"hello"}
    
  3. Run
    $ tail -1 inspector.jsonl | jq -c '.request_data | {user_api_key_alias, user_api_key_team_id}'
    
  4. Observe
    {"user_api_key_alias":"guardrail-client","user_api_key_team_id":"team-prod"}
    

Case 2: pass-through route with a forged identity in the body, real Bedrock call upstream

  1. Run
    $ curl -s -X POST http://localhost:4000/my-llm -H "Authorization: Bearer $PROOF_KEY" -H 'Content-Type: application/json' -d '{"model": "bedrock-haiku-4-5", "messages": [{"role": "user", "content": "Reply with exactly: identity check"}], "max_tokens": 10, "metadata": {"user_api_key_alias": "batch-worker", "user_api_key_team_id": "team-exempt"}}' | jq -c '{content: .choices[0].message.content, usage}'
    
  2. Observe
    {"content":"identity check","usage":{"completion_tokens":5,"prompt_tokens":13,"total_tokens":18,"completion_tokens_details":{"reasoning_tokens":0,"text_tokens":5},"prompt_tokens_details":{"cached_tokens":0,"text_tokens":13,"cache_write_tokens":0,"cache_creation_tokens":0},"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}
    
  3. Run
    $ tail -1 inspector.jsonl | jq -c '.request_data | {user_api_key_alias, user_api_key_team_id}'
    
  4. Observe
    {"user_api_key_alias":"prod-app","user_api_key_team_id":"team-prod"}
    

Case 3: pass-through route with a real x-tenant header and a forged one in the body (extra_headers: [x-tenant])

  1. Run
    $ curl -s -X POST http://localhost:4000/my-llm -H "Authorization: Bearer $PROOF_KEY" -H 'Content-Type: application/json' -H 'x-tenant: tenant-real' -d '{"model": "bedrock-haiku-4-5", "messages": [{"role": "user", "content": "Reply with exactly: header check"}], "max_tokens": 10, "headers": {"x-tenant": "tenant-forged"}}' | jq -c '{content: .choices[0].message.content, usage}'
    
  2. Observe
    {"content":"header check","usage":{"completion_tokens":5,"prompt_tokens":13,"total_tokens":18,"completion_tokens_details":{"reasoning_tokens":0,"text_tokens":5},"prompt_tokens_details":{"cached_tokens":0,"text_tokens":13,"cache_write_tokens":0,"cache_creation_tokens":0},"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}
    
  3. Run
    $ tail -1 inspector.jsonl | jq -c '.request_headers'
    
  4. Observe
    {"host":"127.0.0.1:4000","user-agent":"curl/8.7.1","accept":"*/*","content-type":"application/json","x-tenant":"tenant-real","content-length":"169"}
    

Type

🐛 Bug Fix

Caveats (if any)

Medium

  • /guardrails/apply_guardrail now sends key and team metadata to the vendor
    • These are the identity fields the chat path already sends, with callback config stripped
  • Gray Swan still forwards the proxy's whole metadata bucket, including the auth object
    • This predates the PR and only affects routes whose bucket is litellm_metadata (/v1/messages, /v1/responses)
    • It needs an allowlist inside the Gray Swan guardrail and is left for a follow-up
  • Logging metadata no longer gets the old dump-only keys, such as config and JWT claims
    • A logging integration that read those keys from this metadata now sees them missing
    • On user-defined pass-through this restores the deliberate skip of the per-model budget fields

Low

  • A pass-through body can still list extra guardrails through a top-level guardrails key
    • It can only add guardrails, never remove configured ones, the same as on the chat routes
  • /utils/test_policies_and_guardrails still passes caller-built request data to guardrails
    • The route is admin-only, and an admin can already mint a key with any identity
  • Guardrails that read metadata directly on pass-through now see no identity
    • They saw forged or missing identity before, so this fails safe
  • MCP post-call guardrails still get no identity in request metadata, but it cannot be forged
  • Keys with a token but no api_key (for example ui-token) now send no key hash
    • This matches what spend logs record for those keys
  • PANW Prisma AIRS loses its branch for an empty /guardrails/apply_guardrail request
    • That request now always carries metadata, and the generic fallback still makes a call ID
  • The CLI session token and key secret fixes are proven by unit tests, not the live run
    • Minting a cli-session-... token needs the SSO CLI login flow
  • Pre-existing: user_api_key_auth_metadata differs by route
    • It holds key metadata only on /v1/chat/completions, and key plus team metadata on /v1/messages
  • About ten response-side translation handlers repeat the same "fill litellm_metadata if missing" block

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

@CLAassistant

CLAassistant commented Sep 29, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@codecov

codecov Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing caduri:bugfix/guardrail-apply-endpoint-authenticated-identity (af6c230) with main (02f61c9)

Open in CodSpeed

@caduri
caduri marked this pull request as ready for review September 30, 2026 09:34
@caduri
caduri requested a review from a team September 30, 2026 09:34
@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[High risk] Guardrails now receive authenticated user identity on all request paths.

The PR appears safe to merge; no outstanding or new actionable findings remain.

Summary

The PR replaces caller-claimed guardrail identity with authenticated key metadata across direct, pass-through, unified, and realtime guardrail paths.

  • It sanitizes headers supplied to pass-through guardrails while preserving the upstream request body.
  • It adds regression tests for forged identity, session-token handling, and header forwarding.

Reviews (4) · Last reviewed commit: "test(guardrails): match the pass-through..."

Comment thread litellm/proxy/pass_through_endpoints/pass_through_endpoints.py
Comment thread litellm/proxy/pass_through_endpoints/pass_through_endpoints.py Outdated
@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Comments Outside Diff

These findings could not be posted inline.

  • P1 Guardrails lose inbound headers litellm/proxy/pass_through_endpoints/pass_through_endpoints.py:1131 ▶

    On pass-through pre-call requests, this removes the header fields before the guardrail hook runs without supplying the actual inbound headers instead. GenericGuardrailAPI reads those fields to build the vendor’s request_headers, including values configured through extra_headers. Header-dependent vendor policies therefore cannot evaluate the request as configured. Pass the proxy’s sanitized view of the real headers to the hook separately from the provider body.

  • P1 Upstream body loses headers litellm/proxy/pass_through_endpoints/pass_through_endpoints.py:1133 ▶

    A user-defined pass-through route can use a top-level headers field as part of its JSON payload. This unconditional pop removes it before the body is sent, so the upstream receives a different request even when no guardrail is configured. The repository requires avoiding backwards-incompatible changes without a user-controlled flag. Keep provider payload fields intact while excluding them from the guardrail’s request context.

@caduri

caduri commented Sep 30, 2026

Copy link
Copy Markdown
Author

@greptileai please re-review the latest commit, which addresses both P1 findings from your previous review on this PR

@caduri

caduri commented Sep 30, 2026

Copy link
Copy Markdown
Author

@greptileai please re-review the latest commit, which tightens the tests to assert whole payloads and removes docstrings that repeated the code

caduri added 5 commits October 5, 2026 14:17
…ery path

Guardrails read user_api_key_alias, user_api_key_team_id, the key hash and the request route from request metadata, and the generic guardrail API forwards them to the vendor. The chat path strips caller copies of those fields and writes the real ones, but three other paths did not, so a caller could claim another key, team or request route (which call-type lookups key on)

/guardrails/apply_guardrail passed the body metadata straight to the guardrail. It now drops the fields the chat path treats as untrusted, plus the bare user_api_key and the caller's headers (a slightly wider strip than the chat path, on purpose). Then it adds the authenticated identity and the proxy's real request headers

Pass-through handed the raw body to pre_call_hook. It now runs the chat path's strip on both metadata buckets first, which also stops a body from switching off global guardrails, and drops the body's headers and proxy_server_request so it cannot pick the inbound headers a vendor sees. Those keys were already popped before the upstream send, so the forwarded body does not change. The pass-through guardrail text no longer includes litellm_metadata, which carried the key's identity dump into the scanned payload

The unified guardrail only filled litellm_metadata when it was missing, so a caller-supplied bucket won. It now overwrites the identity fields of an existing bucket and drops any user_api_key_token there, since the proxy never writes one. It leaves user_api_key_auth_metadata alone because on litellm_metadata routes the proxy has already merged team metadata into it

transform_user_api_key_dict_to_metadata used to dump every UserAPIKeyAuth field, including the raw token of non-sk keys, JWT claims, team membership, proxy config and org and project metadata with callback secrets. It now returns only the chat path's identity fields plus user_api_key_key_alias for existing readers, so MCP, pass-through, output-side handlers and realtime transcript guardrails all get that same identity allowlist and see the real user_api_key_alias. The generic guardrail uses user_api_key_token only from litellm_metadata and only when user_api_key_hash is missing, so a CLI session key's raw per-login token never reaches the vendor

The strip and the identity fields live in one place in litellm_pre_call_utils, and the chat path calls them too
Moves the imports this PR added inside functions to the top of their
modules: UserAPIKeyAuth and BaseTranslation in realtime_streaming,
BaseTranslation, StreamingScanKey and LiteLLMProxyRequestSetup in
unified_guardrail (so its annotations no longer need quotes), and the
test-only imports in the unified guardrail, guardrail endpoint, pass-through
and realtime tests. None of them creates a cycle, and import litellm still
does not load the proxy request layer or fastapi

One import stays inside its function: base_translation's
LiteLLMProxyRequestSetup. import litellm loads base_translation before
litellm.Router exists, and litellm_pre_call_utils imports Router, so hoisting
it makes import litellm fail with "cannot import name 'Router' from
'litellm'". It carries a one-line comment saying so
Pass-through guardrails lost their inbound headers once the caller's body
copies were stripped, so an operator's extra_headers allowlist forwarded
nothing to the vendor. Both the pre-call and the post-call guardrail hooks
now get the real request headers in proxy_server_request, built once the
way the chat path builds them: clean_headers drops the proxy's auth header
and a custom litellm_key_header_name, MCP upstream credential headers are
dropped, and redact_credential_headers masks cookies and other credentials

On the success path the headers are attached after the logging object is
created, so they do not land in the logged request. When a pre-call
guardrail blocks, the failure log now receives these cleaned and redacted
headers, which matches what the chat path logs. The pass-through guardrail
leaves proxy_server_request out of the text it scans, and the upstream body
is unchanged because proxy_server_request is popped with the other litellm
params before the send
…ed-request log

The apply_guardrail tests compare the whole request_data again, with the
expected identity built from get_authenticated_identity_metadata and the key
hash pinned, so an extra leaked key or a wrong hash fails them. A new
pass-through test checks that a request blocked by a pre-call guardrail logs
the cleaned and redacted inbound headers. The realtime Gray Swan test now
injects an HTTP transport instead of overriding a private method, so the real
request path runs

_ensure_litellm_metadata is renamed to
_apply_authenticated_identity_to_litellm_metadata, and docstrings that only
repeated the code are gone. The apply_guardrail strip no longer lists
user_api_key, because the authenticated identity always overwrites it. The
identity helpers now return Mapping, since no caller mutates what they return
Main passes endpoint_type to pre_call_hook and reads request.scope before
parsing a body, so the hook fake takes endpoint_type and the mocked request
gets a path
@caduri
caduri force-pushed the bugfix/guardrail-apply-endpoint-authenticated-identity branch from 4b63b53 to af6c230 Compare October 5, 2026 11:44
@caduri

caduri commented Oct 5, 2026

Copy link
Copy Markdown
Author

@greptileai please review af6c230, rebased onto current main with two pass-through test fakes adapted to main's hook signature

def get_authenticated_identity_metadata(user_api_key_dict: UserAPIKeyAuth) -> Mapping[str, object]:
return {
**LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(user_api_key_dict),
"user_api_key": LiteLLMProxyRequestSetup.get_logged_api_key(user_api_key_dict),

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.

Low: Raw custom-auth credentials sent to guardrail vendors

When a custom auth callback returns UserAPIKeyAuth(api_key="my-custom-auth-credential-abc123"), the constructor leaves this non-sk-, non-JWT credential unchanged, and this helper copies it into both user_api_key_hash and user_api_key. The new /apply_guardrail and realtime paths forward these fields to Generic/GraySwan services, allowing the recipient to reuse the caller's bearer credential; hash or omit opaque credentials in both fields before constructing vendor-facing identity metadata.

@veria-ai

veria-ai Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

PR overview

The PR updates guardrail identity propagation so guardrails receive the authenticated caller’s identity across request paths, including /apply_guardrail and realtime requests.

One issue remains open: custom-auth credentials that are neither sk- keys nor JWTs can be forwarded unchanged in identity metadata to external guardrail services. This exposes reusable bearer credentials to those recipients when the affected custom-auth configuration is used. No issues have been fixed or addressed yet.

Open issues (1)

Fixed/addressed: 0 · PR risk: 6/10

This branch has not been deployed

No deployments
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