Skip to content

Fix: Propagate every plugin header mutation in extproc and forwardproxy - #760

Open
JoshSag wants to merge 1 commit into
rossoctl:mainfrom
s-and-p-team:lane/listener-header-parity
Open

Fix: Propagate every plugin header mutation in extproc and forwardproxy#760
JoshSag wants to merge 1 commit into
rossoctl:mainfrom
s-and-p-team:lane/listener-header-parity

Conversation

@JoshSag

@JoshSag JoshSag commented Aug 16, 2026

Copy link
Copy Markdown

The problem

A plugin's header write does not reach the wire in two of your three
pipeline-running listeners.

reverseproxy already gets this right. Its forwarding path syncs the whole
pipeline header set onto the outgoing request, and the comment there states the
bug it was fixing (authlib/listener/reverseproxy/server.go:270-289):

Propagate every header mutation the inbound pipeline made to the forwarded
request. pctx.Headers started as a clone of r.Header, so plugins' set /
replace / delete operations on it are the intended backend-facing header set.
Only Authorization used to be forwarded, silently dropping any other injected
header (e.g. static-inject's x-api-key).

extproc and forwardproxy still have the behaviour that comment describes as
the bug: they forward Authorization and nothing else. This PR brings them to
parity.

Who this affects today, with no lineage involved

Grepping the plugins that write to pctx.Headers:

plugin writes effect in extproc / forwardproxy today
staticinject a configurable header name (SafeSetHeader(pctx.Headers, target, …), plugin.go:221) the injected header never reaches the backend
staticinject pctx.Headers.Del("Authorization") (plugin.go:229) the deletion is not honoured — the old code only ever set Authorization, so a plugin asking to strip it was ignored
cpex arbitrary key/value pairs (manager_cpex.go:492) same — dropped
tokenexchange, tokenbroker, jwtvalidation Authorization unaffected; that one header was already special-cased

So this is a general correctness fix to your own code. It is the reason the PR
is worth taking on its own merits, independent of anything else we are
proposing.

The change

extproc/server.go gains a generic withHeaderMutation: it diffs
pctx.Headers against a clone taken before the pipeline ran, and emits the
difference as SetHeaders / RemoveHeaders on the ProcessingResponse. It
skips :-prefixed pseudo-headers (:authority governs routing) and
Content-Length / Content-Encoding (managed by the body-rewrite path and the
transport) — the same exclusions reverseproxy makes.

forwardproxy/server.go takes the equivalent sync block.

Both retire the Authorization special case. Every upstream writer emits
"Bearer " + token, so the dedicated extract-and-re-prefix path was the
identity function on all real inputs — and it mangled non-Bearer schemes, since
ExtractBearer returns empty for them. Removing it makes the four ext_proc
handlers three lines shorter each and drops an auth import from the file.
After this, no header is special in any listener.

A second, smaller fix in the same file: a 4-line authorityOf helper
(server.go:755), used at 5 sites. The inbound ext_proc handlers never set
pctx.Host, while the outbound ones did — and pipeline.SessionEvent
documents Host for both directions, with reverseproxy always populating it.
That is an upstream omission rather than anything we need; it is separated out
here so you can judge it on its own.

Tests

Six listener-level regression tests, in two new files:

TestExtProc_Outbound_TraceRewriteReachesWire
TestExtProc_OutboundBody_TraceRewriteReachesWire
TestExtProc_Outbound_UnchangedTraceHeadersEmitNothing
TestExtProc_Outbound_ArbitraryHeaderReachesWire
TestExtProc_Outbound_DeletedHeaderIsRemoved
TestExtProc_Outbound_PseudoHeadersNeverEmitted
TestExtProc_Authority            (107-line table over all handler sites + both header forms)

They sit at the listener rather than in a plugin's suite deliberately: the
assertion is about what appears on the ProcessingResponse — the boundary a
plugin-level test structurally cannot observe. ArbitraryHeaderReachesWire,
DeletedHeaderIsRemoved and PseudoHeadersNeverEmitted are the ones that pin
the general behaviour; they use ordinary header names, not ours.

No lineage vocabulary in this diff

This is checkable, and we would rather you check it than take our word. The
production diff carries no lineage vocabulary:

git diff main...<branch> -- authbridge/authlib/listener/extproc/server.go \
                            authbridge/authlib/listener/forwardproxy/server.go \
  | grep -inE "lineage|traceparent|tracestate|exchange\.id"
# no matches

Measured on the branch: no matches — the production diff mentions no telemetry
concept at all. The two new test files do mention traceparent/tracestate:
their regression fixture is a plugin that rewrites those headers, chosen
precisely because header mutations that must survive to the wire are what this
fix is about. Test vocabulary, not listener vocabulary.

Verification

Run under golang:1.26 on this branch, mirroring .github/workflows/ci.yaml:

gate result
go vet ./... (authlib) PASS
go build ./... (authlib) PASS
go test -race -cover ./... (authlib) PASS — 46 packages ok, 0 failed
go vet + build + test -race on both cmd/authbridge-{envoy,proxy} (GOWORK=off) PASS
lite variant (7 exclude_plugin_* tags) PASS
go mod tidy byte-clean × 3 modules PASS
gofmt -l 16 dirty files — exactly the same 16 as main itself, measured on both. This branch adds no gofmt drift and touches none of those files

Deliberately out of scope

extauthz (waypoint mode) is a fourth pipeline-running listener with the same
Authorization-only pattern (extauthz/server.go:86-92). We have not touched it:
we do not run that mode and cannot test the change end to end. Bringing it to
parity would be a natural follow-up, and the shape of the fix here should
transfer directly.


Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

Summary by CodeRabbit

  • New Features

    • Plugin-driven request header additions, updates, and removals are now forwarded correctly.
    • Host and authority values are propagated consistently during request processing.
    • Header changes made during body processing are preserved.
    • Transport-managed and HTTP/2 pseudo-headers remain protected from unintended changes.
  • Bug Fixes

    • Removed Authorization-specific handling that could prevent other header mutations from reaching upstream services.
    • Ensured deleted headers are removed and unchanged headers are not unnecessarily emitted.

reverseproxy already syncs the pipeline's whole header set onto the forwarded
request, and its comment states the bug it fixed:

    Only Authorization used to be forwarded, silently dropping any other
    injected header (e.g. static-inject's x-api-key).

extproc and forwardproxy still behave the way that comment describes. This
brings them to parity.

extproc gains a generic withHeaderMutation: diff pctx.Headers against a clone
taken before the pipeline ran, emit the difference as SetHeaders/RemoveHeaders.
It skips ':'-prefixed pseudo-headers, which govern routing, and
Content-Length/Content-Encoding, which the body-rewrite path and the transport
manage — the same exclusions reverseproxy makes. forwardproxy takes the
equivalent block.

Both drop the Authorization special case. Every writer in-tree emits
"Bearer "+token, so extract-and-re-prefix was the identity function on all real
inputs, and it mangled non-Bearer schemes because ExtractBearer returns empty
for them. Removing it takes three lines out of each of the four ext_proc
handlers and drops the auth import from the file. No header is special in any
listener now.

Affected today, with no telemetry involved: static-inject writes a configurable
header name (plugin.go:221) and deletes Authorization (plugin.go:229) — neither
reached the wire, the deletion because the old path only ever set that header.
cpex writes arbitrary pairs (manager_cpex.go:492).

Also here, separable in review: a 4-line authorityOf helper used at five sites.
The inbound ext_proc handlers never set pctx.Host while the outbound ones did,
though pipeline.SessionEvent documents Host for both directions and reverseproxy
always populated it. A 107-line table test covers every handler site and both
header forms.

Six listener-level regression tests come with this, asserting at the
ProcessingResponse layer that a plugin-level test cannot observe. Three use
ordinary header names to pin the general behaviour: an arbitrary header reaches
the wire, a deleted header is removed, pseudo-headers are never emitted.

Out of scope: extauthz (waypoint mode) has the same Authorization-only pattern
at server.go:86-92 and is untouched here.

Signed-off-by: YehoshuaSagron <ysagron@gmail.com>
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 71ce0f79-9191-4e57-bd64-25ab79f9c5e8

📥 Commits

Reviewing files that changed from the base of the PR and between 08b25a9 and 4440ef9.

📒 Files selected for processing (4)
  • authbridge/authlib/listener/extproc/server.go
  • authbridge/authlib/listener/extproc/server_authority_test.go
  • authbridge/authlib/listener/extproc/server_headerdiff_test.go
  • authbridge/authlib/listener/forwardproxy/server.go

Included review availability: Your plan includes up to 1 review per rolling hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The ext-proc listener now derives host context consistently and propagates general pipeline header mutations. The forward-proxy listener forwards added, changed, and deleted headers. Tests cover authority fallback, trace headers, arbitrary headers, deletions, and pseudo-headers.

Changes

Pipeline header propagation

Layer / File(s) Summary
Authority context propagation
authbridge/authlib/listener/extproc/server.go, authbridge/authlib/listener/extproc/server_authority_test.go
The listener uses authorityOf to set Pipeline.Context.Host from :authority or host. Tests cover inbound and outbound processing.
Ext-proc header mutation generation
authbridge/authlib/listener/extproc/server.go, authbridge/authlib/listener/extproc/server_headerdiff_test.go
The listener compares original and final headers and emits mutations for additions, changes, and removals. Pseudo-headers and transport-managed content headers remain excluded. Tests cover header and body phases.
Forward-proxy header synchronization
authbridge/authlib/listener/forwardproxy/server.go
The forward-proxy listener forwards general pipeline header mutations instead of only Authorization changes. Content-Length and Content-Encoding remain excluded from body transport handling.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 4440e

The change propagates plugin header additions and deletions across the affected listeners and aligns authority handling, with reported validation checks passing; no actionable merge-blocking risk remains beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ExtProcListener
  participant Pipeline
  participant ForwardProxy
  participant Upstream
  Client->>ExtProcListener: Send request
  ExtProcListener->>Pipeline: Process request
  Pipeline-->>ExtProcListener: Return mutated headers and body
  ExtProcListener->>ForwardProxy: Emit header and body mutations
  ForwardProxy->>Upstream: Forward synchronized headers and body
Loading

Suggested reviewers: ibrahim2595, huang195, kellyaa

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: propagating plugin header mutations in extproc and forwardproxy.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@huang195
huang195 marked this pull request as ready for review August 17, 2026 19:05
@huang195
huang195 requested a review from a team as a code owner August 17, 2026 19:05

@huang195 huang195 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Header-propagation change is correct and worth taking; I verified the parts that could bite:

  • forwardproxy (server.go:325-347) — checked ordering: pctx.Headers is a full r.Header.Clone() (line 220), the hop-by-hop strip (lines 359-368) runs after the sync so Proxy-Authorization can't be re-introduced upstream, and the pctx.BodyMutated() block still owns Content-Length. No leak.
  • Authorization special case removal — confirmed every pctx.Headers writer emits "Bearer "+token (jwtvalidation:381, tokenbroker:303, tokenexchange:707), so ExtractBearer + re-prefix was indeed the identity function on real inputs. placeholder_test.go covers the inbound Authorization path through the handlers, so that path keeps regression coverage.
  • append_action — Envoy's ext_proc reads the deprecated append bool (default false → setCopy), per mutation_utils.cc:165-177, so omitting AppendAction replaces rather than appends. Matches the retired helpers' behaviour.
  • No double-emit — header and body phases are mutually exclusive (server.go:113-138).
  • No plugin uses pctx.Headers as a scratchpad, so generalizing propagation leaks nothing internal; honouring staticinject's Del("Authorization") (plugin.go:229) is a security improvement in its own right.

One blocker: the bundled authorityOf change populates inbound Host from a caller-controlled authority, and inbound pctx.Host feeds ibac's un-guarded host-bypass, opa's policy input, and jwtvalidation's per-host audience. That needs to be split out or guarded before merge. Details inline.

Areas reviewed: Go (ext_proc / forward-proxy listeners), tests
Commits: 1 commit, signed off
CI status: all 19 checks passing (CodeRabbit still running)

Direction: pipeline.Inbound,
Method: getHeader(headers, ":method"),
Scheme: getHeader(headers, ":scheme"),
Host: authorityOf(headers),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

must-fix — The header-propagation fix is sound, but this hunk (and its twin at line 190) is not the neutral telemetry fix the description claims. On the inbound path :authority/Host is caller-controlled, and pctx.Host is read by decision-making plugins, not just recorded:

  • plugins/ibac/plugin.go:352matchesAnyHost(p.bypassHosts, pctx.Host)pctx.Skip("host_bypass"), with no direction guard. defaultBypassHosts includes keycloak/spire/otel, plus whatever agent_llm_host is set to. Today in ext_proc inbound this branch is inert because pctx.Host is ""; after this change a caller who sets Host: keycloak... skips IBAC judging entirely.
  • plugins/opa/plugin.go:525"host": pctx.Host becomes caller-controlled policy input.
  • plugins/jwtvalidation/plugin.go:393 — with audience_mode: per-host, the expected audience is derived from the caller-supplied authority.

This repo already documents the hazard and guards for it: plugins/cpex/plugin.go:306-310 gates matchesAnyHost behind pctx.Direction == pipeline.Outbound, with the comment "the Host header is attacker-controlled and identity has NOT been pre-validated."

Two ways forward, either is fine: (a) drop the two inbound authorityOf hunks and keep the outbound consolidation (lines 469/511, a pure no-op refactor); or (b) land them together with a direction guard on ibac's host-bypass check. Worth noting reverseproxy already populates inbound Host, so ibac's exposure pre-dates this PR — but this widens it to the ext_proc sidecar path, and that shouldn't ride along in a PR framed as header propagation.

// headerMapToHTTP copies into pctx.Headers and whose :authority governs routing;
// and Content-Length / Content-Encoding, managed by withBodyMutation and the
// transport.
func withHeaderMutation(resp *extprocv3.ProcessingResponse, pctx *pipeline.Context, orig http.Header) *extprocv3.ProcessingResponse {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

suggestion — With the Authorization special case retired, replaceTokenResponse (line 888) and replaceTokenBodyResponse (line 863) have no callers left. The five references in placeholder_test.go (lines 14, 31, 103, 106, 132) are comments, not calls, and now describe a path production no longer takes. Deleting both helpers and rewording those comments to name withHeaderMutation keeps the next reader from tracing a dead path.

// (whose separator is "; ") — no plugin rewrites Cookie today, and
// one that does must split this out rather than discover it here.
set = append(set, &corev3.HeaderValueOption{
Header: &corev3.HeaderValue{Key: strings.ToLower(k), RawValue: []byte(strings.Join(vv, ","))},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit — Two edges worth a line of comment or a follow-up:

  • headerMapToHTTP (line 766) uses h.Set, so a header that arrived on the wire with duplicate entries is already collapsed to its last value in pctx.Headers. Unchanged headers emit nothing so nothing regresses, but for a header a plugin does mutate, the emitted SetHeaders replaces all wire values with the collapsed one.
  • A plugin doing pctx.Headers[k] = nil instead of Del(k) lands here rather than in the remove loop, emitting an empty RawValue — Envoy drops empty values without keep_empty_value, so the effect is right by accident. Treating a zero-length slice as a delete makes it right by construction.


// TestExtProc_Outbound_DeletedHeaderIsRemoved: a plugin deleting a header
// must emit RemoveHeaders — the narrow two-name diff could not express this.
func TestExtProc_Outbound_DeletedHeaderIsRemoved(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

suggestion — All six new cases go through outboundRequest. handleInbound/handleInboundBody took the identical change, and inbound coverage today is only placeholder_test.go's Authorization case — the one header that worked before. One inbound variant of ArbitraryHeaderReachesWire + DeletedHeaderIsRemoved would pin the general behaviour on both paths; the harness already supports it.

)

// hostCapture records the pctx.Host the listener built, so a test can assert
// what plugins actually see (Host is what SessionEvent.Host and the lineage

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit — This comment says Host is what "the lineage plugin's lineage.peer.host fact" is derived from, which is hard to square with "That is an upstream omission rather than anything we need" in the PR body. The grep in the description is scoped to the two production files, so it's accurate as written — but the honest framing matters here, because the motivation is exactly what a reviewer weighs against the inbound-authority risk in my other comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New/ToDo

Development

Successfully merging this pull request may close these issues.

3 participants