security: strip untrusted identity headers when no auth backend configured#1446
security: strip untrusted identity headers when no auth backend configured#1446yossiovadia wants to merge 5 commits into
Conversation
✅ Deploy Preview for vllm-semantic-router ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
👥 vLLM Semantic Team NotificationThe following members have been identified for the changed files in this PR and have been automatically assigned: 📁
|
897ad3f to
b2b4df0
Compare
|
Note: The authz-rbac integration test sends raw identity headers from the test client without JWT validation. This test passes with the default |
0f3ba58 to
152bce2
Compare
152bce2 to
c6b3695
Compare
…oofing (vllm-project#1445) Without an auth backend, identity headers (x-authz-user-id, x-authz-user-groups) come directly from the client and can be spoofed. This allows any client to impersonate any user, bypassing role-based routing, per-user rate limits, and memory isolation. Fix: add authz.trust_identity_headers config flag (default: true for backward compatibility). When set to false, the router strips identity headers from incoming requests and logs a warning. - Add TrustIdentityHeaders field to AuthzConfig (pointer for nil = default true) - Add ShouldTrustIdentityHeaders() method - Strip identity headers in handleRequestHeaders when trust is disabled - Default true preserves backward compatibility with existing auth backend deployments (ext_authz, Envoy Gateway JWT, oauth2-proxy, etc.) Deployments without an auth backend should set: authz: trust_identity_headers: false Fixes vllm-project#1445 Signed-off-by: Yossi Ovadia <yovadia@redhat.com>
Signed-off-by: Yossi Ovadia <yovadia@redhat.com>
c6b3695 to
9dbad1d
Compare
Signed-off-by: Yossi Ovadia <yovadia@redhat.com>
Signed-off-by: Yossi Ovadia <yovadia@redhat.com>
✅ Supply Chain Security Report — All Clear
Scanned at |
There was a problem hiding this comment.
Pull request overview
Adds an explicit authz.trust_identity_headers configuration flag to prevent client spoofing of identity headers in deployments without an auth backend, by stripping configured identity headers at request header processing time.
Changes:
- Introduces
TrustIdentityHeadersconfig +ShouldTrustIdentityHeaders()defaulting totruefor backward compatibility. - Strips configured identity headers from incoming requests when
trust_identity_headers: false, with a warning log. - Adds a new e2e “security audit” test suite plus demo/e2e configs.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/semantic-router/pkg/extproc/processor_req_header.go | Strips identity headers when trust is disabled to mitigate spoofing. |
| src/semantic-router/pkg/config/config.go | Adds config flag + helper for defaulting behavior. |
| e2e/testing/10-security-audit-test.py | Adds security-oriented e2e tests (incl. identity header stripping scenario). |
| e2e/config/router-runtime.json | Adds runtime JSON fixture for e2e/demo flows. |
| e2e/config/envoy.session-affinity-demo.yaml | Adds Envoy demo config for session affinity/dynamic routing. |
| e2e/config/config.session-affinity-demo.yaml | Adds router config for session affinity demo scenario. |
| config/config.yaml | Sets trust_identity_headers: true explicitly in sample config. |
| if ctx.Headers[userIDHeader] != "" || ctx.Headers[userGroupsHeader] != "" { | ||
| logging.Warnf("Stripped untrusted identity headers (%s, %s) — trust_identity_headers is false. "+ | ||
| "Set authz.trust_identity_headers: true if an auth backend injects these headers.", | ||
| userIDHeader, userGroupsHeader) | ||
| delete(ctx.Headers, userIDHeader) |
There was a problem hiding this comment.
The stripping condition only triggers when the header values are non-empty. If a request includes the identity headers with an empty value, they won’t be deleted, leaving behavior dependent on downstream interpretation of “present but empty”. Strip based on key presence (map lookup) rather than value content, and delete each header independently if present.
| if ctx.Headers[userIDHeader] != "" || ctx.Headers[userGroupsHeader] != "" { | |
| logging.Warnf("Stripped untrusted identity headers (%s, %s) — trust_identity_headers is false. "+ | |
| "Set authz.trust_identity_headers: true if an auth backend injects these headers.", | |
| userIDHeader, userGroupsHeader) | |
| delete(ctx.Headers, userIDHeader) | |
| _, hasUserID := ctx.Headers[userIDHeader] | |
| _, hasUserGroups := ctx.Headers[userGroupsHeader] | |
| if !hasUserID && !hasUserGroups { | |
| return | |
| } | |
| logging.Warnf("Stripped untrusted identity headers (%s, %s) — trust_identity_headers is false. "+ | |
| "Set authz.trust_identity_headers: true if an auth backend injects these headers.", | |
| userIDHeader, userGroupsHeader) | |
| if hasUserID { | |
| delete(ctx.Headers, userIDHeader) | |
| } | |
| if hasUserGroups { |
| userIDHeader := r.Config.Authz.Identity.GetUserIDHeader() | ||
| userGroupsHeader := r.Config.Authz.Identity.GetUserGroupsHeader() | ||
| if ctx.Headers[userIDHeader] != "" || ctx.Headers[userGroupsHeader] != "" { |
There was a problem hiding this comment.
Header names are case-insensitive at the HTTP layer, but ctx.Headers is keyed by exact string. This can allow bypass of stripping if incoming header keys and configured header names differ by case (e.g., X-Authz-User-Id vs x-authz-user-id) depending on how Envoy/ext_proc populates header.Key. Normalize header keys consistently (e.g., lowercasing keys when populating ctx.Headers and lowercasing configured header names before lookup/deletion).
| logging.Warnf("Stripped untrusted identity headers (%s, %s) — trust_identity_headers is false. "+ | ||
| "Set authz.trust_identity_headers: true if an auth backend injects these headers.", | ||
| userIDHeader, userGroupsHeader) |
There was a problem hiding this comment.
Logging a warning on every request that contains the headers can be abused for log amplification (especially under active probing/attack when trust_identity_headers is false). Consider rate-limiting/sampling this warning and/or emitting a metric counter for stripped headers; optionally include ctx.RequestID to aid investigation without increasing log volume.
| def test_spoofed_identity_headers_are_stripped(self): | ||
| """Spoofed auth headers should be stripped when no auth backend is configured.""" | ||
| self.print_test_header( | ||
| "Auth Header Stripping", | ||
| "Spoofed x-authz-user-id should be stripped without auth backend", | ||
| ) | ||
|
|
||
| r = eval_text("hello") | ||
| self.assertEqual(r.status_code, 200) | ||
|
|
||
| # The eval API doesn't expose which headers were stripped, but the | ||
| # router log confirms: "Stripped untrusted identity headers" | ||
| # The fix prevents role_bindings from matching spoofed identities, | ||
| # per-user rate limits from being bypassed, and memory isolation | ||
| # from being circumvented. | ||
| self.print_test_result( | ||
| True, | ||
| "Eval completed normally (identity headers stripped by router if present)", | ||
| ) |
There was a problem hiding this comment.
This test doesn’t actually send spoofed identity headers, nor does it assert that stripping occurred (it only asserts a 200). To validate the fix, send a request that includes the configured identity headers (matching Authz.Identity header names) and assert an observable outcome—e.g., assert on captured router logs/metrics, or route behavior that depends on user identity—so the test fails if headers are not stripped.
| // When false, the router strips identity headers on every request and logs | ||
| // a warning. This prevents user impersonation via role_bindings, rate limits, | ||
| // and memory isolation. |
There was a problem hiding this comment.
The comment says it “logs a warning” on every request, but the implementation only logs when one of the identity headers is present. Update the comment to reflect actual behavior (or change logging behavior if the intent is truly “always log”).
| // When false, the router strips identity headers on every request and logs | |
| // a warning. This prevents user impersonation via role_bindings, rate limits, | |
| // and memory isolation. | |
| // When false, the router strips identity headers from incoming requests. | |
| // If identity headers are present and stripped, it logs a warning. This | |
| // prevents user impersonation via role_bindings, rate limits, and memory | |
| // isolation. |

Summary
Fixes #1445 — without an auth backend, any client can spoof
x-authz-user-idandx-authz-user-groupsheaders to impersonate any user, bypassing role-based routing, per-user rate limits, and memory isolation.Fix
Add
authz.trust_identity_headersconfig flag:true(default): trust identity headers — assumes an auth backend (ext_authz, Envoy Gateway JWT, oauth2-proxy, etc.) injects them before the request reaches ext_proc. This preserves backward compatibility.false: strip identity headers from every request, preventing client spoofing.Deployments without an auth backend should add:
Why not auto-detect?
Identity headers can be injected by many mechanisms (ext_authz/Authorino, Envoy Gateway JWT claim_to_headers, oauth2-proxy, Istio RequestAuthentication). The router has no reliable way to distinguish "injected by auth backend" from "set by client" — by the time ext_proc sees the request, both look identical. An explicit config flag gives operators control without breaking existing deployments.
Changes
pkg/config/config.goTrustIdentityHeadersfield +ShouldTrustIdentityHeaders()methodpkg/extproc/processor_req_header.goe2e/testing/10-security-audit-test.pyTest plan
test_spoofed_identity_headers_are_stripped— auth header testmake build-routerpassesgolangci-lint— 0 issues on changed filestruepreserves backward compatibility (existing authz-rbac integration test should pass)