Go · github.com/traefik/traefik/v3
Traefik entrypoint header-name sanitization bypassed via request trailers
Traefik's entrypoint defenses against spoofed trusted header names — aliasHeadersStrategy / underscoreHeadersStrategy in delete or reject mode, and the default forwardedHeaders stripping of client-supplied X-Forwarded-* — scan req.Header only and never req.Trailer. An unauthenticated client can therefore smuggle a sanitized name (an aliasing spelling such as X_Auth_User, or a trusted name such as X-Forwarded-Prefix) as an HTTP/1.1 chunked trailer or an HTTP/2 trailer: reject does not return its documented 400, delete does not remove the name, and Traefik's reverse proxy forwarded the trailer to the backend — with an attacker-chosen value whenever a body-buffering middleware (the retry middleware with status codes, or the buffering middleware) reads the body before the proxy clone. Backends that merge trailers into their header namespace then act on the smuggled name. The fix stops forwarding request trailer values to the backend; the declared trailer names are still forwarded as permitted by RFC 9110 section 6.6.2.
Traefik v2 is not affected: the defect is in the custom reverse proxy introduced in v3 (pkg/proxy/httputil), and v2 uses the Go standard library's httputil.ReverseProxy, which does not forward request trailer values to the backend. Affected v3 lines from v3.2.0 through v3.7.12 include the end-of-life v3.2 through v3.6 lines, which will not receive a fix on their own line; the remedy for those users is to upgrade to v3.7.13.
If you have any questions or comments about this advisory, please open an issue.
Traefik's entrypoint defenses against spoofed header names — aliasHeadersStrategy / underscoreHeadersStrategy in delete or reject mode, and the forwardedHeaders handling that strips client-supplied X-Forwarded-* — scan req.Header only and never req.Trailer, although the handlers' own comments promise to cover "header and trailer". An unauthenticated client can therefore deliver the aliasing name (X_Auth_User, X.Auth.User) or the trusted name itself (X-Forwarded-Prefix, …) as an HTTP/1.1 chunked trailer or an HTTP/2 trailer: reject does not return its documented 400, delete does not remove the name, and the trailer form of an X-Forwarded-* name passes exactly where the header form is stripped. When a body-buffering middleware is in the chain (retry with status codes, or the buffering middleware — both measured), the trailer travels with an attacker-chosen value; measured end-to-end against the trailer-merging component Ubuntu 24.04 ships (pre-fix libevent, CVE-2026-63379), the header X-Forwarded-Prefix: admin is stripped and denied while the identical name as a trailer is acted upon as admin (403 → 200). On bare proxy paths only the trailer name travels (no value), bounding those deployments to name-level effects.
Root cause. All four entrypoint handlers iterate req.Header only — the doc comments promise more than the code does (pkg/server/server_entrypoint_tcp.go):
// removeAliasingHeaders removes any request header and trailer whose name contains a character
// which is neither a letter, a digit, nor a dash, as such a name aliases another header name.
func removeAliasingHeaders(h http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
for key := range req.Header { // ← req.Trailer is never scanned
if isAliasingHeaderName(key) {
delete(req.Header, key)
}
}
h.ServeHTTP(rw, req)
})
}
rejectAliasingHeaders, removeHeadersWithUnderscores and rejectHeadersWithUnderscores share the identical structure (the reject variants return 400 from the same loop). The sibling sanitization forwardedheaders.DeleteXForwardedHeaders (pkg/middlewares/forwardedheaders/forwarded_header.go) also scans req.Header only, so the trusted X-Forwarded-* names whose header form Traefik strips for untrusted clients — the managed XHeadersSet, which includes X-Forwarded-Prefix and X-Forwarded-For — survive in trailer form. Go's HTTP server populates req.Trailer from chunked/HTTP/2 trailers, and Traefik's proxy layer forwards those entries, bypassing the sanitization above.
Contract provenance. The "header and trailer" wording is in the original introducing diffs — 108a52644 (underscoreHeadersStrategy) and 0331801c (aliasHeadersStrategy) — and is unchanged in master (full diff excerpts available on request). The option began as allowHeadersWithUnderscores: false (per the CVE-2026-54763 record) before becoming underscoreHeadersStrategy and then aliasHeadersStrategy. The user-facing documentation describes only "request headers".
Mechanism (why names survive, and when values do too).
Trailer: X_Auth_User declaration makes Go's server move the declared keys into req.Trailer with nil values before the handler runs (net/http/transfer.go, fixTrailer); HTTP/2 does the same from the trailer: field in the initial HEADERS ("Setup Trailers", net/http/internal/httpcommon/httpcommon.go). The entrypoint handlers therefore cannot see the trailer name, but the proxy forwards it. Trailer keys are canonicalized with textproto.CanonicalMIMEHeaderKey, which treats dashes — not underscores — as case separators: the aliasing spelling survives canonicalization as e.g. X_auth_user (visible in the backend dumps in PoC §1) and remains detectable by isAliasingHeaderName, so the fix does not depend on the client's original spelling.req.Trailer only while the body is consumed (readTrailer / copyTrailersToHandlerRequest). On the bare path the reverse proxy calls Request.Clone at handler start, before any body read, so the clone captures nil values — on HTTP/1.1 the trailer field line is then omitted entirely (net/http/header.go, Header.writeSubset writes one line per value), and h2c delivers only the empty key. When a body-buffering middleware runs first, the order reverses: the retry middleware with status codes buffers the body via mirror.NewReusableRequest → io.ReadAll(req.Body) (pkg/middlewares/retry/retry.go, pkg/server/service/loadbalancer/mirror/mirror.go), the values are populated before http.Request.Clone, and they travel to the backend. Buffering triggers for idempotent methods with status alone; POST additionally requires retryNonIdempotentMethod (both measured). Retry and buffering are the two measured paths; the mirroring and failover services use the same mirror.NewReusableRequest helper (pkg/server/service/loadbalancer/mirror/mirror.go, failover/failover.go when errors.status is configured) and share its behavior (not measured). The buffering middleware drains the body eagerly before the proxy too: pkg/middlewares/buffering/buffering.go → oxy's multibuf.New → ioutil.ReadAll (github.com/mailgun/multibuf buffer.go; unset limits fall back to 1 MB DefaultMemBytes) — measured value-preserving with default limits.net/http/internal/http2/server.go) — undeclared fields never appear. On HTTP/1.1 readTrailer parses the entire trailer section with no declaration filter, and mergeSetHeader either rebinds the map when nil (*dst = src) or blindly merges when non-nil (point 4). The rebind is why zero-declaration requests lose undeclared fields at Traefik's observability req.WithContext shallow copy (`pkg/middlewares/observability/observabilitIs your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.