Fix X-Forwarded-Proto for TLS remote upstreams#5594
Conversation
Behind a TLS-terminating load balancer (e.g. an AWS ALB), the proxy's pod-local inbound connection is plain HTTP. httputil.SetXForwarded() derives X-Forwarded-Proto from that inbound hop, so remote upstreams always received "X-Forwarded-Proto: http". Upstreams that enforce HTTPS based on this header responded with 301 redirects, and because every forwarded request kept reporting "http" the proxy looped until hitting the redirect limit, preventing MCP session initialization. Rewrite X-Forwarded-Proto for remote upstreams to either the trusted inbound value (when trustProxyHeaders is enabled) or the actual upstream connection scheme parsed from the target URI. Local backends are unchanged. Fixes stacklok#5567
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #5594 +/- ##
==========================================
+ Coverage 70.05% 70.29% +0.23%
==========================================
Files 659 648 -11
Lines 66884 66023 -861
==========================================
- Hits 46857 46411 -446
+ Misses 16647 16269 -378
+ Partials 3380 3343 -37 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
JAORMX
left a comment
There was a problem hiding this comment.
Got the panel together on this one (spec, standards, security, architecture, MCP protocol, devex) with the higher reasoning model, since this is trust-boundary code and you asked for extra care. Good news first: the fix is sound and well-scoped. It kills the redirect loop, preserves the X-Forwarded-Host removal control, leaves local backends untouched, and the test coverage is solid. No Critical or High. But there's one trust-boundary gap I'd want closed before merge, plus a stale comment that's actively misleading.
The one to fix before merge: unvalidated X-Forwarded-Proto on the trust path
upstreamForwardedProto (transparent_proxy.go:1106) has an asymmetry. The fallback path validates the scheme against {http, https} (the switch at line 1114). The trust path does not... when trustProxyHeaders is on, inboundProto is returned verbatim with no check:
if p.trustProxyHeaders && inboundProto != "" {
return inboundProto // any string passes through
}And inboundProto comes from pr.In.Header.Get("X-Forwarded-Proto") (line 1083), which is client-controllable. The Go stdlib ReverseProxy strips client X-Forwarded-* from the outbound request before Rewrite runs, but pr.In retains whatever the client sent. So when trustProxyHeaders is enabled, an attacker who can reach the proxy directly (port-forward, misconfigured ingress, internal cluster access... the listener is plain HTTP) can set X-Forwarded-Proto: http against an HTTPS upstream and force the very redirect loop this PR exists to fix. Or send arbitrary tokens to a third-party upstream. CWE-601 / OWASP A05:2021.
Now, the caveats: this is gated behind an opt-in flag whose documented purpose is exactly "trust these headers from a reverse proxy," so exploitation requires operator misconfiguration. And the same unvalidated-trust pattern already exists on the SSE response-processor path (sse_response_processor.go:159-166), so this PR doesn't introduce a new class of trust, it adds a second consumer. CRLF injection is NOT possible here... Go's HTTP parser rejects control chars in header values at parse time. So this is Medium, not Higher.
But this is security code, and the fix is one line. Validate inboundProto against {http, https} on the trust path, same as the fallback:
func (p *TransparentProxy) upstreamForwardedProto(inboundProto string) string {
if p.trustProxyHeaders && isHTTPScheme(inboundProto) {
return inboundProto
}
// ... fallback unchanged
}
func isHTTPScheme(s string) bool { return s == "http" || s == "https" }Makes the trust boundary explicit and bounds the reflected value regardless of proxy placement. Add a test row for inboundProto: "httpx" with trustProxyHeaders: true asserting the fallback wins.
Stale // Deprecated comment (cross-confirmed by four reviewers)
The trustProxyHeaders field at transparent_proxy.go:135 reads:
// Deprecated: trustProxyHeaders indicates whether to trust X-Forwarded-* headers (moved to SSEResponseProcessor)
trustProxyHeaders boolThis PR adds a second live reader at line 1107 on the outbound path. The field is no longer "moved to SSEResponseProcessor"... it's actively load-bearing in two directions now (SSE response rewriting, and outbound X-Forwarded-Proto trust). The Deprecated: marker is actively harmful here: a developer doing the cleanup the marker suggests ("this was moved, I can drop the field") would silently regress the fix this PR adds. Per go-style "Keep Comments Synchronized With Code" ("a comment that contradicts the code is worse than no comment"), this is a hard violation. Drop Deprecated: and document both read sites.
Judgement calls
backend_url override and the targetURI source of truth. setXForwardedHeaders runs at line 1144, before the backend_url session-metadata override at lines 1148-1165 that can rewrite pr.Out.URL.Scheme/Host. So the header is derived from the static targetURI, not the actual request destination. I traced this carefully: podBackendURL (line 819) always preserves the targetURI scheme (it bails to targetURI for HTTPS targets, and for HTTP it only swaps the host), so the mismatch is inert today. But it's a latent trap... if backend_url ever carries an independent scheme, the header will silently lie about the wire protocol while pr.Out.URL.Scheme diverges. Two options: cheapest is a one-line comment at line 1110 noting the scheme-equivalence assumption so the next maintainer doesn't "fix" it by reading pr.Out.URL.Scheme and introducing an ordering bug. Cleaner is to move setXForwardedHeaders to after the backend_url block and derive from pr.Out.URL.Scheme. I'd take the comment now and the refactor only if scheme divergence is on the roadmap.
Redirect chains don't re-run Rewrite. followRedirects re-issues via base.RoundTrip, not through the Rewrite closure, so X-Forwarded-Proto stays at the value computed on the first hop across the whole redirect chain. For same-scheme redirects that's fine. The edge case is an HTTP→HTTPS upgrade redirect within a chain... the stale http would carry to the HTTPS target, re-introducing the original bug for that hop. Unlikely in practice (most HTTPS-enforcing servers don't redirect from HTTP to HTTP-then-HTTPS on the same host within a chain the proxy initiates), but it's the same root cause. Worth a one-line note in the upstreamForwardedProto doc, or a recompute inside followRedirects if you want defense-in-depth.
trustProxyHeaders dual-purpose. Re-using the flag for outbound trust is defensible... both uses are "do we trust reverse-proxy-supplied X-Forwarded-*?"... but the two directions (inbound response rewriting vs outbound request-header trust) are independent trust decisions over different headers. Acceptable to collapse them behind one boolean today, but only if the comment is fixed (see above). Don't split the field unless there's a concrete near-term reason to... that's a wide blast radius through the constructor chain for a hypothetical.
Polish
upstreamForwardedProtore-parsesp.targetURIper request (line 1110) when theRewriteclosure already hastargetURLparsed once at line 1134. Pass the parsed URL (or just the scheme string) in instead.- The capture comment at line 1081 ("before SetXForwarded() overwrites it") is slightly misleading...
SetXForwarded()writes topr.Out, notpr.In, so the capture ordering isn't strictly required. The real subtlety is thatpr.In(notpr.Out) is the source becausehttputilstripsX-Forwarded-*from the outbound request beforeRewriteruns. Worth tightening the comment to say that. - Test table is missing the
default:branch of the scheme switch (e.g.targetURI: "ftp://..."assertingwantProto: "") and a junk trusted proto ("ftp"withtrustProxyHeaders: true). One row each closes the gap.
Summary
The fix is correct for the reported bug and the security posture against the usual forwarded-header attacks is solid (no CRLF injection, host removal preserved, local path unchanged, fallback validates). The one thing I'd block on is the unvalidated inboundProto on the trust path... one line to close a real trust-boundary gap in security code. The stale // Deprecated comment is a hard standards violation and should go in the same change. The backend_url ordering and redirect-chain edge cases are judgement calls that can land as follow-ups if you want to keep this tight.
| // receives. Returns an empty string when no usable scheme is found, leaving | ||
| // the value SetXForwarded() already set untouched. | ||
| func (p *TransparentProxy) upstreamForwardedProto(inboundProto string) string { | ||
| if p.trustProxyHeaders && inboundProto != "" { |
There was a problem hiding this comment.
Trust-boundary gap: the fallback path validates the scheme against {http, https} (the switch at line 1114), but the trust path returns inboundProto verbatim with no check. inboundProto comes from pr.In.Header.Get("X-Forwarded-Proto") (line 1083), which is client-controllable... the stdlib ReverseProxy strips client X-Forwarded-* from pr.Out before Rewrite, but pr.In retains whatever the client sent.
When trustProxyHeaders is on, an attacker who can reach the proxy directly (port-forward, misconfigured ingress... the listener is plain HTTP) can set X-Forwarded-Proto: http against an HTTPS upstream and force the redirect loop this PR exists to fix, or send arbitrary tokens to a third-party upstream. CWE-601 / OWASP A05:2021.
Gated behind an opt-in flag, so exploitation requires operator misconfiguration, and the same unvalidated-trust pattern already exists on the SSE response-processor path (sse_response_processor.go:159-166). CRLF injection is NOT possible (Go's parser rejects control chars in header values). So this is Medium, not higher. But this is security code, and the fix is one line:
if p.trustProxyHeaders && isHTTPScheme(inboundProto) {
return inboundProto
}with func isHTTPScheme(s string) bool { return s == "http" || s == "https" }. Add a test row for inboundProto: "httpx" with trustProxyHeaders: true asserting the fallback wins.
There was a problem hiding this comment.
Fixed in d6f779a. Both paths now validate against {http, https} via a shared isHTTPScheme helper:
func (p *TransparentProxy) upstreamForwardedProto(inboundProto, upstreamScheme string) string {
if p.trustProxyHeaders && isHTTPScheme(inboundProto) {
return inboundProto
}
if isHTTPScheme(upstreamScheme) {
return upstreamScheme
}
return ""
}
func isHTTPScheme(s string) bool { return s == "http" || s == "https" }A junk trusted value now falls through to the upstream scheme rather than being reflected. Added the requested test row (inboundProto: "httpx" with trustProxyHeaders: true asserting the fallback wins), plus a non-http upstream scheme row covering the fallback's default branch. Good catch — agreed this belonged in the same change given it's security code.
| if p.trustProxyHeaders && inboundProto != "" { | ||
| return inboundProto | ||
| } | ||
| u, err := url.Parse(p.targetURI) |
There was a problem hiding this comment.
Latent trap: setXForwardedHeaders runs at line 1144, before the backend_url override at lines 1148-1165 that can rewrite pr.Out.URL.Scheme/Host. So the header is derived from the static targetURI, not the actual request destination.
I traced this... podBackendURL (line 819) always preserves the targetURI scheme (bails to targetURI for HTTPS, only swaps host for HTTP), so the mismatch is inert today. But if backend_url ever carries an independent scheme, the header will silently lie about the wire protocol. Cheapest fix: a one-line comment here noting the scheme-equivalence assumption so the next maintainer doesn't "fix" it by reading pr.Out.URL.Scheme and introducing an ordering bug. Cleaner: move setXForwardedHeaders to after the backend_url block and derive from pr.Out.URL.Scheme. I'd take the comment now.
There was a problem hiding this comment.
Took the comment option in d6f779a. setXForwardedHeaders now receives the upstream scheme already parsed in Start (so no per-request re-parse), and the doc on upstreamForwardedProto spells out the assumption and warns the next maintainer off the trap:
upstreamSchemeis the scheme of the statically-configured target URL [...]. It is used deliberately instead ofpr.Out.URL.Scheme: the per-sessionbackend_urloverride that can rewritepr.Out.URLruns after this function [...]. The override only ever swaps the host for HTTP targets and preserves the scheme for HTTPS targets (seepodBackendURL), soupstreamSchemealways matches the wire protocol. Do not "simplify" this to readpr.Out.URL.Scheme.
Left the move-after-backend_url refactor as a potential follow-up if a scheme-divergent backend_url ever lands.
| func (p *TransparentProxy) setXForwardedHeaders(pr *httputil.ProxyRequest) { | ||
| // Capture the client-supplied X-Forwarded-Proto before SetXForwarded() | ||
| // overwrites it with the pod-local inbound scheme. | ||
| inboundProto := pr.In.Header.Get("X-Forwarded-Proto") |
There was a problem hiding this comment.
The comment "before SetXForwarded() overwrites it" is slightly misleading... SetXForwarded() writes to pr.Out, not pr.In, so the capture ordering isn't strictly required. The real subtlety is that pr.In (not pr.Out) is the source because httputil strips X-Forwarded-* from the outbound request before Rewrite runs. Worth tightening the comment to say that, since that's the non-obvious fact a future maintainer needs.
There was a problem hiding this comment.
Tightened in d6f779a. The comment now explains the real subtlety — that pr.In is the source because httputil strips the client headers from the outbound request:
// Read the client-supplied X-Forwarded-Proto from pr.In. We must read it
// from the inbound request (not pr.Out) because httputil.ReverseProxy strips
// all client-supplied X-Forwarded-* headers from the outbound request before
// Rewrite runs, so pr.Out never carries the original client value.Address review on the X-Forwarded-Proto fix:
- Validate the inbound X-Forwarded-Proto against {http, https} on the
trust path, matching the fallback. The value is client-controllable
(the proxy listener is plain HTTP), so an unvalidated trusted value
let a caller reflect arbitrary tokens or force "http" against an HTTPS
upstream, re-creating the redirect loop. Add an isHTTPScheme helper
used by both paths (CWE-601 / OWASP A05:2021).
- Drop the stale "Deprecated: moved to SSEResponseProcessor" marker on
trustProxyHeaders; the field now has two live read sites. Document both.
- Pass the upstream scheme (already parsed in Start) into
setXForwardedHeaders instead of re-parsing targetURI per request.
- Document why the static target scheme is used rather than
pr.Out.URL.Scheme (the backend_url override runs after this function)
and note the same-scheme assumption across redirect chains.
- Tighten the inbound-capture comment to explain pr.In is the source
because httputil strips X-Forwarded-* from the outbound request.
- Add test rows: junk trusted proto falls back; non-HTTP upstream
scheme leaves the SetXForwarded default intact.
Summary
Behind a TLS-terminating load balancer (e.g. an AWS ALB), the proxy's pod-local inbound connection is plain HTTP.
httputil.SetXForwarded()derivesX-Forwarded-Protofrom that inbound hop, so remote upstreams always receivedX-Forwarded-Proto: http. Upstreams that enforce HTTPS based on this header respond with 301 redirects, and because every forwarded request keeps reportinghttp, the proxy loops until it hits the redirect limit — preventing MCP session initialization.X-Forwarded-Protofor remote upstreams insetXForwardedHeaders. The value is now either:X-Forwarded-Proto(e.g. set by the ALB) whentrustProxyHeadersis enabled, orupstreamForwardedProtohelper and expand the table-driven test coverage.Fixes #5567
Type of change
Test plan
task test)task lint-fix)TestSetXForwardedHeaderswas expanded into a table covering: local backend (unchangedhttp), remote https/http upstreams (derive from upstream scheme), trusted inbound proto honored (and winning over upstream scheme), trust enabled without an inbound header (falls back to upstream scheme), and untrusted inbound proto ignored.API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.Does this introduce a user-facing change?
Yes.
MCPRemoteProxy(and any remote HTTP/SSE proxy) deployed behind a TLS-terminating load balancer now forwards a correctX-Forwarded-Prototo TLS upstreams, fixing infinite HTTP→HTTPS redirect loops during MCP session initialization. WhentrustProxyHeadersis enabled, a trusted inboundX-Forwarded-Proto(e.g. from an ALB) is honored; otherwise the proxy uses the upstream connection scheme.Implementation plan
Approved implementation plan
Root cause:
setXForwardedHeaderscallshttputil.ProxyRequest.SetXForwarded(), which setsX-Forwarded-Protofrom the pod-local inbound connection (pr.In.TLS == nil→http). Client-supplied forwarded headers are stripped by httputil beforeRewriteruns, so the original HTTPS scheme is lost.Fix (in
pkg/transport/proxy/transparent/transparent_proxy.go):X-Forwarded-ProtobeforeSetXForwarded()overwrites it.X-Forwarded-Prototo:trustProxyHeadersis enabled and present, elsehttp/https).xforwarded_test.gointo a table-driven test for the matrix above.Special notes for reviewers
p.isRemote), matching the issue (MCPRemoteProxy). Local pod-to-pod backends keep the existing inbound-derived scheme.backend_urloverride keeps the original scheme for HTTPS targets).trustProxyHeadersknob means no new config surface is introduced; the documented option now also governsX-Forwarded-Protopassthrough, consistent with the SSE response processor's existing use of it.Generated with Claude Code