Traefik entrypoint header-name sanitization bypassed via request trailers
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
계획된 패치 주기 내 조치(60일 이내)
CVSS 벡터 · 메트릭
CVSS 벡터 정보 없음
상세 설명
Summary
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.
Patches
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>Summary
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.
Details
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):
1// removeAliasingHeaders removes any request header and trailer whose name contains a character 2// which is neither a letter, a digit, nor a dash, as such a name aliases another header name. 3func removeAliasingHeaders(h http.Handler) http.Handler { 4 return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 5 for key := range req.Header { // ← req.Trailer is never scanned 6 if isAliasingHeaderName(key) { 7 delete(req.Header, key) 8 } 9 }10 h.ServeHTTP(rw, req)11 })12}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).
- Name pre-fill at parse time. The client's
Trailer: X_Auth_Userdeclaration makes Go's server move the declared keys intoreq.Trailerwith nil values before the handler runs (net/http/transfer.go,fixTrailer); HTTP/2 does the same from thetrailer: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 withtextproto.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 byisAliasingHeaderName, so the fix does not depend on the client's original spelling. - Value survival depends on who reads the body first. Trailer values are appended to
req.Traileronly while the body is consumed (readTrailer/copyTrailersToHandlerRequest). On the bare path the reverse proxy callsRequest.Cloneat 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.writeSubsetwrites one line per value), and h2c delivers only the empty key. When a body-buffering middleware runs first, the order reverses: the retry middleware withstatuscodes buffers the body viamirror.NewReusableRequest→io.ReadAll(req.Body)(pkg/middlewares/retry/retry.go,pkg/server/service/loadbalancer/mirror/mirror.go), the values are populated beforehttp.Request.Clone, and they travel to the backend. Buffering triggers for idempotent methods withstatusalone; POST additionally requiresretryNonIdempotentMethod(both measured). Retry and buffering are the two measured paths; the mirroring and failover services use the samemirror.NewReusableRequesthelper (pkg/server/service/loadbalancer/mirror/mirror.go,failover/failover.gowhenerrors.statusis configured) and share its behavior (not measured). Thebufferingmiddleware drains the body eagerly before the proxy too:pkg/middlewares/buffering/buffering.go→ oxy'smultibuf.New→ioutil.ReadAll(github.com/mailgun/multibufbuffer.go; unset limits fall back to 1 MBDefaultMemBytes) — measured value-preserving with default limits. - Undeclared trailers: transit depends on whether anything else was declared (measured). On HTTP/2 the standard library server copies only pre-declared trailers ("Only copy it over it was pre-declared",
net/http/internal/http2/server.go) — undeclared fields never appear. On HTTP/1.1readTrailerparses the entire trailer section with no declaration filter, andmergeSetHeadereither 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 observabilityreq.WithContextshallow copy (pkg/middlewares/observability/observability.go,entrypoint.go) — measured: they never leave the entrypoint even on buffered chains. But a bait declaration (any clean name, e.g.X-Dummy) keeps the map non-nil, and the blind merge then writes the undeclared field into the shared map at body EOF — measured on the retry-buffered chain: the backend receivesmap[X-Dummy:[1] X_auth_user:[attacker-value]]and presence-based policies flip; the bare path is unaffected and delivers onlymap[X-Dummy:[]]. - Delete-mode stickiness depends on the merge semantics (measured).
readTrailermerges parsed trailer fields viamergeSetHeader, whose non-nil branch is a blindmaps.Copy(net/http/transfer.go) — a key deleted by a handler is re-added with its value at body EOF. Measured on a bare Go server (delete(r.Trailer, "X_auth_user")before draining the body): HTTP/1.1 —map[X_auth_user:[]]→map[X_auth_user:[attacker-value]](re-added); HTTP/2 —map[X_auth_user:[]]→map[](stays deleted:copyTrailersToHandlerRequestchecks the live map).
Deliberate trailer-forwarding behavior (regression tests). Traefik deliberately does not forward request trailers on the bare proxy chain, locked by the regression tests pkg/proxy/httputil/trailer_test.go and pkg/proxy/fast/trailer_test.go (added 86b5642f, 2026-06-25; extended d427dccf, 2026-06-29): "trailers arrive after the body, once routing and security decisions have already been made, so forwarding them could raise security concerns in Traefik." The measured buffered-chain value survival (mechanism point 2) defeats exactly that locked invariant — the tests exercise only the bare chain — and the name-level h2c forwarding (empty keys) passes the tests' assertion (Header.Get is empty whether the key is absent or empty-valued): neither regression test catches this finding. The value-level path thus bypasses a deliberate, test-locked security invariant.
Preconditions.
- An entrypoint whose sanitization is relied upon:
aliasHeadersStrategy/underscoreHeadersStrategyset todeleteorreject, or the defaultforwardedHeadersstripping ofX-Forwarded-*for untrusted clients. - A request carrying the name as a declared trailer (HTTP/1.1 chunked, or HTTP/2), or — on HTTP/1.1 buffered chains only — as an undeclared trailer field riding a bait declaration (mechanism point 3).
- For downstream impact: a backend that merges trailers into its header namespace (pre-fix libevent CVE-2026-63379 — still what Ubuntu 24.04 ships —, pre-fix blaze CVE-2026-73495, or custom code) or consumes trailer fields in a trust decision.
- For the value-level path: the retry middleware with
statuscodes, thebufferingmiddleware, or another body-buffering middleware, in the chain.
Precedent and scope. This is the next variant of Traefik's own aliasing family — CVE-2026-33433 (GHSA-qr99-7898-vr7c), CVE-2026-39858 (GHSA-5m6w-wvh7-57vm), CVE-2026-54763 (GHSA-x677-9fxg-v5c5) — and Traefik's Security Decisions state the in-scope line: "a spelling that survives the entrypoint sanitisation and still reaches the backend as the trusted name". The trailer spelling is precisely that. The downstream merge class is cross-ecosystem: libevent CVE-2026-63379 (run live in PoC §3) and blaze/http4s CVE-2026-73495 (GHSA-46q4-43ph-c6fr, fixed ef3e666).
Boundaries (measured). Declaring Content-Length, Transfer-Encoding or Trailer as trailer fields is rejected with 400 by Go's server; Host and Connection pass through name-level. The FastProxy forwarding mode (opt-in [experimental] fastProxy) does not forward trailers; the default reverse-proxy path for http:// backends does (PoC §3). HTTP/3 (quic-go) trailer semantics are untested. Undeclared trailers: HTTP/2 drops them entirely; on HTTP/1.1 they transit only via a bait declaration on buffered chains (mechanism point 3).
PoC
Verified against a source-built Traefik (master @ 237f13c6, built with Go 1.27.0; all harness backends built with Go 1.27.0 — the trailer behaviors cited in Details are version-sensitive net/http internals). Complete harness (clients, backends, configs, logs) available on request; the raw chunked requests below are HTTP/1.1 and reproducible with nc/python.
1. Core bypass (aliasHeadersStrategy = reject). Static config:
1[entryPoints.web] 2 address = ":8090" 3 [entryPoints.web.http] 4 aliasHeadersStrategy = "reject" 5 6[providers.file] 7 filename = "dynamic.toml" 8 watch = truedynamic.toml: router PathPrefix(/) → service → h2c://127.0.0.1:8081 (a Go echo backend that drains the body and prints r.Trailer). Requests (CRLF line endings; 5/0 are chunk sizes):
1POST / HTTP/1.1 2Host: 127.0.0.1:8090 3Connection: close 4Transfer-Encoding: chunked 5Trailer: X_Auth_User 6 75 8hello 9010X_Auth_User: attacker-valueResults:
1header X_Auth_User (curl -H "X_Auth_User: x") → HTTP 400 (rejected, as designed) 2trailer X_Auth_User (request above) → HTTP 200 (bypass: not rejected) 3trailer X.Auth.User → HTTP 200 (bypass) 4trailer X-Forwarded-Prefix → HTTP 200 (trusted-name trailer passes)Backend evidence: TRAILERS: map[X_auth_user:[]], map[X.auth.user:[]], map[X-Forwarded-Prefix:[]]. The deprecated underscoreHeadersStrategy = "reject" behaves identically.
aliasHeadersStrategy = "delete" (same setup, delete in place of reject): header X_Auth_User / X.Auth.User → 200, backend HEADERS contain neither (deleted, as designed); trailer X_Auth_User → 200, backend TRAILERS: map[X_auth_user:[]] — the trailer form survives delete.
2. Bare-path downstream semantics (name-level). Same router, backend h2c://127.0.0.1:8082 running a trailer-merging backend (trailers folded over headers, CGI-style name normalization — the CVE-2026-63379 pattern) that authorizes /presence on the merged key and /value on X-Auth-User == "admin":
1/presence, no trailer (control) → 403 DENIED 2/presence, trailer X_Auth_User → 200 AUTHORIZED ← presence flip, empty value 3/value, header X-Auth-User: admin + trailer → merged-user="" ← legitimate value erased3. Real CVE'd component flipped through Traefik — value-level. Backend: Ubuntu 24.04's libevent-2.1-7t64 2.1.12-stable-9ubuntu2 (pre-fix; the merge was fixed only in 2.1.13) plus a small (≈100-line) evhttp server that authorizes via evhttp_find_header(req->input_headers, ...) (/prefix grants admin when X-Forwarded-Prefix == "admin"; server source available on request; build: gcc server.c -levent). Router adds the retry middleware:
1[http.routers.lib] 2 entryPoints = ["web"] 3 rule = "PathPrefix(`/`)" 4 service = "lib" 5 middlewares = ["retry-lib"] 6 7[http.middlewares.retry-lib.retry] 8 attempts = 2 9 status = ["500-599"]10 11[http.services.lib.loadBalancer.servers]12 [http.services.lib.loadBalancer.servers.s1]13 url = "http://127.0.0.1:8083"Measured matrix (server log shows the merged input_headers):
| Request | Result through Traefik |
|---|---|
header X-Forwarded-Prefix: admin | 403 DENIED — stripped by forwardedHeaders |
trailer X-Forwarded-Prefix: admin (chunked, declared; GET) | 200 ADMIN (prefix=admin) — log: X-Forwarded-Prefix: admin merged |
trailer X_Auth_User: attacker-value (GET) | 200 AUTHORIZED (presence) — log: X_auth_user: attacker-value |
| same trailer request, retry middleware removed (clean restart) | 403 DENIED — value dropped, field line omitted; log shows only Trailer: X_auth_user |
| same trailer request, direct to libevent (no Traefik) | 200 ADMIN (prefix=admin) — CVE-2026-63379 baseline |
The value survives because the retry middleware buffers the body before the proxy clone (mechanism point 2). The same value path holds on h2c outbound (merge backend logs trailer=map[X-Auth-User:[admin]] → 200 AUTHORIZED (value=admin)) and with the buffering middleware in place of retry (/value trailer → 200 AUTHORIZED (value=admin), /xff trailer → 200 ADMIN).
Bait declaration (measured). Declaring a clean Trailer: X-Dummy while additionally sending the undeclared X_Auth_User: attacker-value in the trailer section: on the retry-buffered chain the backend receives TRAILERS: map[X-Dummy:[1] X_auth_user:[attacker-value]] → 200 AUTHORIZED (presence policies flip on X_auth_user); the zero-declaration control still delivers map[]; the bare path delivers only map[X-Dummy:[]] (clone precedes the merge). Over HTTP/2 inbound with buffering (client_h2c through a retry chain with retryNonIdempotentMethod): backend trailer=map[X_auth_user:[attacker-value]] → 200 AUTHORIZED (presence).
X-Forwarded-For IP-trust (same chain, measured). Same router and retry middleware, backend h2c://127.0.0.1:8082 running the merge backend with an added /xff route that grants access when the merged X-Forwarded-For equals 203.0.113.7 — the classic IP-allowlist pattern:
1header X-Forwarded-For: 203.0.113.7 → 403 DENIED (xff) 2 backend log: merged XFF = "127.0.0.1" (Traefik stripped the client value and set its own) 3trailer X-Forwarded-For: 203.0.113.7 (GET, retry) → 200 ADMIN (xff) 4 backend log: trailer=map[X-Forwarded-For:[203.0.113.7]] 5trailer X-Forwarded-For: 203.0.113.7 (POST, retry without retryNonIdempotentMethod → not buffered) → 403 DENIED — merged XFF empty (bare-path value drop)4. Framing names and protocols. Trailer Content-Length, Host, Connection, Transfer-Encoding → 400 (Go rejects); Host, Connection → 200, backend TRAILERS: map[Connection:[] Host:[]]. HTTP/2 prior-knowledge client with trailer X_Auth_User → Traefik → h2c backend: 200, backend TRAILERS: map[X_auth_user:[]] — same name-only outcome as PoC §2. HTTP/3 untested.
Impact
Kind of vulnerability. A bypass of Traefik's documented defenses against spoofed trusted names. reject promises a 400 and delete promises removal for aliasing names; forwardedHeaders strips client-supplied X-Forwarded-* — and all of it applies to headers only, leaving the trailer channel open, with attacker-chosen values on body-buffering chains.
Who is impacted. Operators who enabled delete/reject to close the aliasing spoofing class (the documented mitigation for the CVE-2026-33433/39858/54763 family), and deployments whose backends trust X-Forwarded-* names or proxy-set identity headers — including the classic X-Forwarded-For IP-trust pattern, where Traefik strips the client's XFF from headers while the trailer form reaches trailer-merging backends. No opt-in option is required for the X-Forwarded-* path: the stripping is the default for untrusted clients. The value-level path additionally requires a body-buffering middleware (retry with status codes, or buffering) — mainstream documented features: the buffering middleware's documentation states that attaching it buffers the request body before forwarding, and the retry middleware's documentation example configures status = ["400","500-599"] — though no deployment telemetry is available to quantify their prevalence.
Verified harm scenarios.
- Broken protection contract. Trailer-form aliasing names are neither rejected nor removed — the documented mitigation has a side door the operator believes is closed.
- Presence-based authorization bypass. Trailer-merging upstreams authorizing on the presence of a trusted identity key flip their decision:
403 → 200 AUTHORIZEDthrough Traefik on an h2c merge backend (PoC §2) and on the real pre-fix libevent component (PoC §3). - Value-level identity spoofing. On body-buffering chains the trailer carries the attacker's value:
X-Forwarded-Prefix: admindelivered through Traefik authorizes as admin on the real CVE'd merge backend, while the identical header form is stripped and denied (PoC §3) — the CVE-2026-63379-class value injection chained through Traefik's own value-preserving middleware behavior. - Legitimate identity value erased. A trailer-merging upstream folds the empty trailer over the identity header —
X-Auth-User: adminbecomes empty in the merged view (PoC §2). This typically denies rather than grants; its relevance is the erasure primitive and availability of the legitimate identity. - Routing-header name channel.
HostandConnectiontrailer fields pass Go's validation and reach the backend name-level (PoC §4); a trailer-merging upstream's virtual-host view is overwritten with an empty value.
Explicitly out of scope (verified). Value delivery requires a body-buffering middleware in the chain — on bare proxy paths values are dropped (PoC §3); the FastProxy path does not forward trailers; Content-Length/Transfer-Encoding/Trailer trailer fields are rejected with 400.
Recommended fix
Make the four entrypoint handlers and forwardedheaders.DeleteXForwardedHeaders iterate req.Trailer as well as req.Header — deleting matching trailer entries in delete mode and returning 400 in reject mode — at the exact place the header filtering already happens. The entrypoint stage sees every declared name (pre-filled before the handler) and covers all of HTTP/2 (undeclared fields are dropped by the stdlib server — mechanism point 3); reject returns 400 for those. On HTTP/1.1 buffered chains, names that appear only at body EOF — undeclared fields riding a bait declaration (mechanism point 3) and deleted keys re-added by the blind mergeSetHeader merge (mechanism point 4) — bypass the entrypoint stage, so the sanitization must be re-applied after the body's final read for both modes and for DeleteXForwardedHeaders; at that point the request may already be partially forwarded, so the second stage strips rather than rejects — reject deployments get delete-semantics for the late names. HTTP/3 (quic-go) may not pre-fill declared trailer keys before the handler at all (untested); there the post-body stage is the only certain defense. The fix sanitizes only the names the operator's policy targets — it does not drop the trailer channel, so legitimate trailers such as gRPC's grpc-status are unaffected.
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.
참고 자료 6
링크 내용 불러오는 중…