GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-V67P-PHPQ-FC8X

Vulnerability from github – Published: 2026-09-10 23:04 – Updated: 2026-09-10 23:04
VLAI
Summary
Traefik entrypoint header-name sanitization bypassed via request trailers
Details

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

  • https://github.com/traefik/traefik/releases/tag/v3.7.13

For more information

If you have any questions or comments about this advisory, please open an issue.

Original Description ### 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`):
// 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).** 1. *Name pre-fill at parse time.* The client's `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. 2. *Value survival depends on who reads the body first.* Trailer values are appended to `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. 3. *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.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/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 receives `map[X-Dummy:[1] X_auth_user:[attacker-value]]` and presence-based policies flip; the bare path is unaffected and delivers only `map[X-Dummy:[]]`. 4. *Delete-mode stickiness depends on the merge semantics (measured).* `readTrailer` merges parsed trailer fields via `mergeSetHeader`, whose non-nil branch is a blind `maps.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: `copyTrailersToHandlerRequest` checks 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.** 1. An entrypoint whose sanitization is relied upon: `aliasHeadersStrategy` / `underscoreHeadersStrategy` set to `delete` or `reject`, or the default `forwardedHeaders` stripping of `X-Forwarded-*` for untrusted clients. 2. 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). 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. 4. For the value-level path: the retry middleware with `status` codes, the `buffering` middleware, 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:
[entryPoints.web]
  address = ":8090"
  [entryPoints.web.http]
    aliasHeadersStrategy = "reject"

[providers.file]
  filename = "dynamic.toml"
  watch = true
`dynamic.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):
POST / HTTP/1.1
Host: 127.0.0.1:8090
Connection: close
Transfer-Encoding: chunked
Trailer: X_Auth_User

5
hello
0
X_Auth_User: attacker-value

Results:
header  X_Auth_User   (curl -H "X_Auth_User: x")  → HTTP 400  (rejected, as designed)
trailer X_Auth_User   (request above)             → HTTP 200  (bypass: not rejected)
trailer X.Auth.User                                → HTTP 200  (bypass)
trailer 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"`:
/presence, no trailer (control)                  → 403 DENIED
/presence, trailer X_Auth_User                   → 200 AUTHORIZED      ← presence flip, empty value
/value,    header X-Auth-User: admin + trailer   → merged-user=""       ← legitimate value erased
**3. 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:
[http.routers.lib]
  entryPoints = ["web"]
  rule = "PathPrefix(`/`)"
  service = "lib"
  middlewares = ["retry-lib"]

[http.middlewares.retry-lib.retry]
  attempts = 2
  status = ["500-599"]

[http.services.lib.loadBalancer.servers]
  [http.services.lib.loadBalancer.servers.s1]
    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:
header  X-Forwarded-For: 203.0.113.7                  → 403 DENIED (xff)
        backend log: merged XFF = "127.0.0.1"  (Traefik stripped the client value and set its own)
trailer X-Forwarded-For: 203.0.113.7  (GET, retry)    → 200 ADMIN (xff)
        backend log: trailer=map[X-Forwarded-For:[203.0.113.7]]
trailer 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.** 1. *Broken protection contract.* Trailer-form aliasing names are neither rejected nor removed — the documented mitigation has a side door the operator believes is closed. 2. *Presence-based authorization bypass.* Trailer-merging upstreams authorizing on the presence of a trusted identity key flip their decision: `403 → 200 AUTHORIZED` through Traefik on an h2c merge backend (PoC §2) and on the real pre-fix libevent component (PoC §3). 3. *Value-level identity spoofing.* On body-buffering chains the trailer carries the attacker's value: `X-Forwarded-Prefix: admin` delivered 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. 4. *Legitimate identity value erased.* A trailer-merging upstream folds the empty trailer over the identity header — `X-Auth-User: admin` becomes 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. 5. *Routing-header name channel.* `Host` and `Connection` trailer 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.
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/traefik/traefik/v3"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "3.2.0"
            },
            {
              "fixed": "3.7.13"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-88004"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-436",
      "CWE-807"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-10T23:04:21Z",
    "nvd_published_at": "2026-09-10T15:17:54Z",
    "severity": "HIGH"
  },
  "details": "## Summary\n\nTraefik\u0027s entrypoint defenses against spoofed trusted header names \u2014 `aliasHeadersStrategy` / `underscoreHeadersStrategy` in `delete` or `reject` mode, and the default `forwardedHeaders` stripping of client-supplied `X-Forwarded-*` \u2014 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\u0027s reverse proxy forwarded the trailer to the backend \u2014 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.\n\nTraefik 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\u0027s `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.\n\n## Patches\n\n- https://github.com/traefik/traefik/releases/tag/v3.7.13\n\n## For more information\n\nIf you have any questions or comments about this advisory, please [open an issue](https://github.com/traefik/traefik/issues).\n\n\u003cdetails\u003e\n\u003csummary\u003eOriginal Description\u003c/summary\u003e\n\n### Summary\n\nTraefik\u0027s entrypoint defenses against spoofed header names \u2014 `aliasHeadersStrategy` / `underscoreHeadersStrategy` in `delete` or `reject` mode, and the `forwardedHeaders` handling that strips client-supplied `X-Forwarded-*` \u2014 scan `req.Header` only and never `req.Trailer`, although the handlers\u0027 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`, \u2026) 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 \u2014 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 \u2192 200`). On bare proxy paths only the trailer name travels (no value), bounding those deployments to name-level effects.\n\n### Details\n\n**Root cause.** All four entrypoint handlers iterate `req.Header` only \u2014 the doc comments promise more than the code does (`pkg/server/server_entrypoint_tcp.go`):\n\n```go\n// removeAliasingHeaders removes any request header and trailer whose name contains a character\n// which is neither a letter, a digit, nor a dash, as such a name aliases another header name.\nfunc removeAliasingHeaders(h http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {\n\t\tfor key := range req.Header {          // \u2190 req.Trailer is never scanned\n\t\t\tif isAliasingHeaderName(key) {\n\t\t\t\tdelete(req.Header, key)\n\t\t\t}\n\t\t}\n\t\th.ServeHTTP(rw, req)\n\t})\n}\n```\n\n`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 \u2014 the managed `XHeadersSet`, which includes `X-Forwarded-Prefix` and `X-Forwarded-For` \u2014 survive in trailer form. Go\u0027s HTTP server populates `req.Trailer` from chunked/HTTP/2 trailers, and Traefik\u0027s proxy layer forwards those entries, bypassing the sanitization above.\n\n**Contract provenance.** The \"header and trailer\" wording is in the original introducing diffs \u2014 `108a52644` (underscoreHeadersStrategy) and `0331801c` (aliasHeadersStrategy) \u2014 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\".\n\n**Mechanism (why names survive, and when values do too).**\n\n1. *Name pre-fill at parse time.* The client\u0027s `Trailer: X_Auth_User` declaration makes Go\u0027s 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 \u2014 not underscores \u2014 as case separators: the aliasing spelling survives canonicalization as e.g. `X_auth_user` (visible in the backend dumps in PoC \u00a71) and remains detectable by `isAliasingHeaderName`, so the fix does not depend on the client\u0027s original spelling.\n2. *Value survival depends on who reads the body first.* Trailer values are appended to `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 \u2014 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` \u2192 `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` \u2192 oxy\u0027s `multibuf.New` \u2192 `ioutil.ReadAll` (github.com/mailgun/multibuf `buffer.go`; unset limits fall back to 1 MB `DefaultMemBytes`) \u2014 measured value-preserving with default limits.\n3. *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`) \u2014 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\u0027s observability `req.WithContext` shallow copy (`pkg/middlewares/observability/observability.go`, `entrypoint.go`) \u2014 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 \u2014 measured on the retry-buffered chain: the backend receives `map[X-Dummy:[1] X_auth_user:[attacker-value]]` and presence-based policies flip; the bare path is unaffected and delivers only `map[X-Dummy:[]]`.\n4. *Delete-mode stickiness depends on the merge semantics (measured).* `readTrailer` merges parsed trailer fields via `mergeSetHeader`, whose non-nil branch is a blind `maps.Copy` (`net/http/transfer.go`) \u2014 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 \u2014 `map[X_auth_user:[]]` \u2192 `map[X_auth_user:[attacker-value]]` (re-added); HTTP/2 \u2014 `map[X_auth_user:[]]` \u2192 `map[]` (stays deleted: `copyTrailersToHandlerRequest` checks the live map).\n\n**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 \u2014 the tests exercise only the bare chain \u2014 and the name-level h2c forwarding (empty keys) passes the tests\u0027 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.\n\n**Preconditions.**\n\n1. An entrypoint whose sanitization is relied upon: `aliasHeadersStrategy` / `underscoreHeadersStrategy` set to `delete` or `reject`, or the default `forwardedHeaders` stripping of `X-Forwarded-*` for untrusted clients.\n2. A request carrying the name as a declared trailer (HTTP/1.1 chunked, or HTTP/2), or \u2014 on HTTP/1.1 buffered chains only \u2014 as an undeclared trailer field riding a bait declaration (mechanism point 3).\n3. For downstream impact: a backend that merges trailers into its header namespace (pre-fix libevent CVE-2026-63379 \u2014 still what Ubuntu 24.04 ships \u2014, pre-fix blaze CVE-2026-73495, or custom code) or consumes trailer fields in a trust decision.\n4. For the value-level path: the retry middleware with `status` codes, the `buffering` middleware, or another body-buffering middleware, in the chain.\n\n**Precedent and scope.** This is the next variant of Traefik\u0027s own aliasing family \u2014 CVE-2026-33433 (GHSA-qr99-7898-vr7c), CVE-2026-39858 (GHSA-5m6w-wvh7-57vm), CVE-2026-54763 (GHSA-x677-9fxg-v5c5) \u2014 and Traefik\u0027s 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 \u00a73) and blaze/http4s CVE-2026-73495 (GHSA-46q4-43ph-c6fr, fixed `ef3e666`).\n\n**Boundaries (measured).** Declaring `Content-Length`, `Transfer-Encoding` or `Trailer` as trailer fields is rejected with `400` by Go\u0027s 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 \u00a73). 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).\n\n### PoC\n\nVerified against a source-built Traefik (`master` @ `237f13c6`, built with **Go 1.27.0**; all harness backends built with Go 1.27.0 \u2014 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`.\n\n**1. Core bypass (`aliasHeadersStrategy = reject`).** Static config:\n\n```toml\n[entryPoints.web]\n  address = \":8090\"\n  [entryPoints.web.http]\n    aliasHeadersStrategy = \"reject\"\n\n[providers.file]\n  filename = \"dynamic.toml\"\n  watch = true\n```\n\n`dynamic.toml`: router `PathPrefix(`/`)` \u2192 service \u2192 `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):\n\n```\nPOST / HTTP/1.1\nHost: 127.0.0.1:8090\nConnection: close\nTransfer-Encoding: chunked\nTrailer: X_Auth_User\n\n5\nhello\n0\nX_Auth_User: attacker-value\n\n```\n\nResults:\n\n```\nheader  X_Auth_User   (curl -H \"X_Auth_User: x\")  \u2192 HTTP 400  (rejected, as designed)\ntrailer X_Auth_User   (request above)             \u2192 HTTP 200  (bypass: not rejected)\ntrailer X.Auth.User                                \u2192 HTTP 200  (bypass)\ntrailer X-Forwarded-Prefix                         \u2192 HTTP 200  (trusted-name trailer passes)\n```\n\nBackend evidence: `TRAILERS: map[X_auth_user:[]]`, `map[X.auth.user:[]]`, `map[X-Forwarded-Prefix:[]]`. The deprecated `underscoreHeadersStrategy = \"reject\"` behaves identically.\n\n`aliasHeadersStrategy = \"delete\"` (same setup, `delete` in place of `reject`): header `X_Auth_User` / `X.Auth.User` \u2192 `200`, backend HEADERS contain neither (deleted, as designed); trailer `X_Auth_User` \u2192 `200`, backend `TRAILERS: map[X_auth_user:[]]` \u2014 **the trailer form survives `delete`**.\n\n**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 \u2014 the CVE-2026-63379 pattern) that authorizes `/presence` on the merged key and `/value` on `X-Auth-User == \"admin\"`:\n\n```\n/presence, no trailer (control)                  \u2192 403 DENIED\n/presence, trailer X_Auth_User                   \u2192 200 AUTHORIZED      \u2190 presence flip, empty value\n/value,    header X-Auth-User: admin + trailer   \u2192 merged-user=\"\"       \u2190 legitimate value erased\n```\n\n**3. Real CVE\u0027d component flipped through Traefik \u2014 value-level.** Backend: Ubuntu 24.04\u0027s `libevent-2.1-7t64` 2.1.12-stable-9ubuntu2 (pre-fix; the merge was fixed only in 2.1.13) plus a small (\u2248100-line) `evhttp` server that authorizes via `evhttp_find_header(req-\u003einput_headers, ...)` (`/prefix` grants admin when `X-Forwarded-Prefix == \"admin\"`; server source available on request; build: `gcc server.c -levent`). Router adds the retry middleware:\n\n```toml\n[http.routers.lib]\n  entryPoints = [\"web\"]\n  rule = \"PathPrefix(`/`)\"\n  service = \"lib\"\n  middlewares = [\"retry-lib\"]\n\n[http.middlewares.retry-lib.retry]\n  attempts = 2\n  status = [\"500-599\"]\n\n[http.services.lib.loadBalancer.servers]\n  [http.services.lib.loadBalancer.servers.s1]\n    url = \"http://127.0.0.1:8083\"\n```\n\nMeasured matrix (server log shows the merged `input_headers`):\n\n| Request | Result through Traefik |\n|---|---|\n| header `X-Forwarded-Prefix: admin` | `403 DENIED` \u2014 stripped by `forwardedHeaders` |\n| trailer `X-Forwarded-Prefix: admin` (chunked, declared; GET) | **`200 ADMIN (prefix=admin)`** \u2014 log: `X-Forwarded-Prefix: admin` merged |\n| trailer `X_Auth_User: attacker-value` (GET) | **`200 AUTHORIZED (presence)`** \u2014 log: `X_auth_user: attacker-value` |\n| same trailer request, retry middleware removed (clean restart) | `403 DENIED` \u2014 value dropped, field line omitted; log shows only `Trailer: X_auth_user` |\n| same trailer request, direct to libevent (no Traefik) | `200 ADMIN (prefix=admin)` \u2014 CVE-2026-63379 baseline |\n\nThe 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]]` \u2192 `200 AUTHORIZED (value=admin)`) and with the `buffering` middleware in place of retry (`/value` trailer \u2192 `200 AUTHORIZED (value=admin)`, `/xff` trailer \u2192 `200 ADMIN`).\n\n**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]]` \u2192 `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]]` \u2192 `200 AUTHORIZED (presence)`.\n\n**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` \u2014 the classic IP-allowlist pattern:\n\n```\nheader  X-Forwarded-For: 203.0.113.7                  \u2192 403 DENIED (xff)\n        backend log: merged XFF = \"127.0.0.1\"  (Traefik stripped the client value and set its own)\ntrailer X-Forwarded-For: 203.0.113.7  (GET, retry)    \u2192 200 ADMIN (xff)\n        backend log: trailer=map[X-Forwarded-For:[203.0.113.7]]\ntrailer X-Forwarded-For: 203.0.113.7  (POST, retry without retryNonIdempotentMethod \u2192 not buffered) \u2192 403 DENIED \u2014 merged XFF empty (bare-path value drop)\n```\n\n**4. Framing names and protocols.** Trailer `Content-Length, Host, Connection, Transfer-Encoding` \u2192 `400` (Go rejects); `Host, Connection` \u2192 `200`, backend `TRAILERS: map[Connection:[] Host:[]]`. HTTP/2 prior-knowledge client with trailer `X_Auth_User` \u2192 Traefik \u2192 h2c backend: `200`, backend `TRAILERS: map[X_auth_user:[]]` \u2014 same name-only outcome as PoC \u00a72. HTTP/3 untested.\n\n### Impact\n\n**Kind of vulnerability.** A bypass of Traefik\u0027s documented defenses against spoofed trusted names. `reject` promises a `400` and `delete` promises removal for aliasing names; `forwardedHeaders` strips client-supplied `X-Forwarded-*` \u2014 and all of it applies to headers only, leaving the trailer channel open, with attacker-chosen values on body-buffering chains.\n\n**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 \u2014 including the classic `X-Forwarded-For` IP-trust pattern, where Traefik strips the client\u0027s 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`) \u2014 mainstream documented features: the buffering middleware\u0027s documentation states that attaching it buffers the request body before forwarding, and the retry middleware\u0027s documentation example configures `status = [\"400\",\"500-599\"]` \u2014 though no deployment telemetry is available to quantify their prevalence.\n\n**Verified harm scenarios.**\n\n1. *Broken protection contract.* Trailer-form aliasing names are neither rejected nor removed \u2014 the documented mitigation has a side door the operator believes is closed.\n2. *Presence-based authorization bypass.* Trailer-merging upstreams authorizing on the presence of a trusted identity key flip their decision: `403 \u2192 200 AUTHORIZED` through Traefik on an h2c merge backend (PoC \u00a72) and on the real pre-fix libevent component (PoC \u00a73).\n3. *Value-level identity spoofing.* On body-buffering chains the trailer carries the attacker\u0027s value: `X-Forwarded-Prefix: admin` delivered through Traefik authorizes as admin on the real CVE\u0027d merge backend, while the identical header form is stripped and denied (PoC \u00a73) \u2014 the CVE-2026-63379-class value injection chained through Traefik\u0027s own value-preserving middleware behavior.\n4. *Legitimate identity value erased.* A trailer-merging upstream folds the empty trailer over the identity header \u2014 `X-Auth-User: admin` becomes empty in the merged view (PoC \u00a72). This typically denies rather than grants; its relevance is the erasure primitive and availability of the legitimate identity.\n5. *Routing-header name channel.* `Host` and `Connection` trailer fields pass Go\u0027s validation and reach the backend name-level (PoC \u00a74); a trailer-merging upstream\u0027s virtual-host view is overwritten with an empty value.\n\n**Explicitly out of scope (verified).** Value delivery requires a body-buffering middleware in the chain \u2014 on bare proxy paths values are dropped (PoC \u00a73); the FastProxy path does not forward trailers; `Content-Length`/`Transfer-Encoding`/`Trailer` trailer fields are rejected with `400`.\n\n### Recommended fix\n\nMake the four entrypoint handlers and `forwardedheaders.DeleteXForwardedHeaders` iterate `req.Trailer` as well as `req.Header` \u2014 deleting matching trailer entries in `delete` mode and returning `400` in `reject` mode \u2014 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 \u2014 mechanism point 3); `reject` returns `400` for those. On HTTP/1.1 buffered chains, names that appear only at body EOF \u2014 undeclared fields riding a bait declaration (mechanism point 3) and deleted keys re-added by the blind `mergeSetHeader` merge (mechanism point 4) \u2014 bypass the entrypoint stage, so the sanitization must be re-applied after the body\u0027s 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 \u2014 `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\u0027s policy targets \u2014 it does not drop the trailer channel, so legitimate trailers such as gRPC\u0027s `grpc-status` are unaffected.\n\n\u003c/details\u003e\n\n---",
  "id": "GHSA-v67p-phpq-fc8x",
  "modified": "2026-09-10T23:04:21Z",
  "published": "2026-09-10T23:04:21Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/traefik/traefik/security/advisories/GHSA-v67p-phpq-fc8x"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-88004"
    },
    {
      "type": "WEB",
      "url": "https://github.com/traefik/traefik/pull/13822"
    },
    {
      "type": "WEB",
      "url": "https://github.com/traefik/traefik/commit/55bbda4f65e0f9c533c870983a767a7081126db7"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/traefik/traefik"
    },
    {
      "type": "WEB",
      "url": "https://github.com/traefik/traefik/releases/tag/v3.7.13"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:H/SI:H/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Traefik entrypoint header-name sanitization bypassed via request trailers"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…