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> ---
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.
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> ---