
Introduction
This one came out of a run of request smuggling research I have been doing across HTTP proxies. It is a bug in Traefik, and it lets one unauthenticated request make a completely different user receive somebody else's response.
It is not the only thing that run turned up. I am starting with this one because it is fixed and public. The rest are still going through disclosure, and some of the products are still being patched, so those stay private until the vendors have shipped. I will write them up as they land. Until then, this one.
Traefik keeps a pool of open TCP connections to each backend so it does not have to dial a new one for every request. That pool is shared. Your request and my request can travel down the same socket, one after the other. The bug lets an attacker leave a mess on one of those shared sockets. Traefik then hands that socket to somebody else, and that person reads the attacker's leftovers as their own response.
It takes one request to set up, and that request is a plain HTTP/2 CONNECT. No
authentication, no special configuration, no unusual backend. The attacker never sees
anything interesting themselves. Someone else does.
I reported it on 13 July 2026. Traefik confirmed it on 21 July and shipped fixes on 24 July across three release branches. It is CVE-2026-71324 / GHSA-3ccp-42pg-hgv6, rated High (CVSS 4.0, 7.0), fixed in v2.11.53, v3.6.24 and v3.7.9. If you run Traefik, go upgrade and then come back.
So let us take a look at it: how I went looking, what the bug actually is, the wire captures, what it takes to reproduce, what Traefik shipped, and the part of my own report that turned out to be wrong.
TL;DR
Traefik forwards a client's HTTP/2 or HTTP/3
CONNECTto the backend as if it were an ordinary request, body and all. Go'snet/httpwrites aCONNECTbody onto the socket with no framing at all, so if the attacker puts a whole HTTP/1.1 request in that body, the backend sees two requests instead of one.The backend answers the
CONNECTwith something like405, keeps the connection alive, and starts working on the second request. Traefik sees a finished response on a healthy connection and puts that socket back in the shared pool, with a reply still owed on it.The next user to get that socket reads the reply that was owed. It was never theirs.
What you need to know first
Three ideas. If you already know them, skip to The bug.
Request smuggling
Two programs read the same stream of bytes and disagree about where one request ends and the next begins. The proxy in front counts one request. The server behind counts two. The second one is the smuggled one, and it never passed through whatever checks the proxy does.
RFC 9112, the HTTP/1.1 spec itself, has a section on this. It calls it a technique that "exploits differences in protocol parsing among various recipients to hide additional requests (which might otherwise be blocked or disabled by policy) within an apparently harmless request."
Connection pools
A reverse proxy does not open a fresh TCP connection to the backend for every request. It keeps a pool of warm ones and reuses them. Traefik's default is up to 200 idle connections per backend.
The pool is what turns a parsing bug into somebody else's problem. Anything left behind on a pooled socket gets applied to whoever gets that socket next. That person is on a different TLS session, from a different IP, and has nothing to do with the attacker.
CONNECT
GET and POST have bodies that mean something to HTTP. CONNECT does not.
CONNECT example.com:443 asks a proxy to open a raw TCP tunnel and then stop being
HTTP. Everything after the 200 is opaque bytes.
In HTTP/2 and HTTP/3 the same idea lives on one stream. The stream's DATA frames carry the tunnelled bytes. The one rule that matters here:
a CONNECT body means nothing until the tunnel is actually open. Before a 2xx, those bytes have nowhere to go. After a non-2xx, they should be thrown away.
That is the rule Traefik was breaking.
How I went looking
I was not reading Traefik's source hoping something would jump out. I was working through one bug class across HTTP proxies, and Traefik was a target on that list.
The class is a dirty connection pool. Not a parsing disagreement in the classic
Content-Length versus Transfer-Encoding sense, but this: get a proxy to leave one
backend socket in a state it does not know about, then let the pool hand that socket to
the next person. The desync on its own is not the interesting part. The pool is. It is
what turns one attacker request into somebody else's problem.
Working from a class instead of a hunch gives you a checklist. For a Go proxy the questions are always the same four:
- Where does a client-controlled request reach the shared
http.Transportunmodified? - Is there any method whose body Go writes onto the socket without framing?
- What does the proxy do when the backend answers something it did not expect?
- Which responses put the socket back in the pool, and which ones burn it?
CONNECT answers the second question by itself, and it is the only method that does.
That is what made it worth chasing.
So I read the proxy code looking for places where all four line up, and wrote down eight candidates before testing any of them. That part matters, because most of them were wrong:
| what I suspected | where | outcome |
|---|---|---|
| client CONNECT reaching the backend transport, H2 frontend | pkg/proxy/httputil/proxy.go |
confirmed |
| the same over HTTP/3 | pkg/proxy/httputil/proxy.go |
confirmed |
| ForwardAuth re-sending a buffered body unframed | pkg/middlewares/auth/forward.go |
confirmed as a vehicle |
| FastProxy pooling a 2xx CONNECT tunnel | pkg/proxy/fast/connpool.go |
could not reproduce |
| FastProxy read buffer with no barrier | pkg/proxy/fast/connpool.go |
could not reproduce |
| H2 stream lifecycle into an upstream pool | pkg/proxy/fast/connpool.go |
safe, upstream H2 is stream framed |
| a 101 upgrade leaking into the pool | pkg/proxy/fast/connpool.go |
safe, 101 excludes the socket |
| H3 stream reset reaching the upstream | pkg/server/server_entrypoint_tcp_http3.go |
safe, there is no upstream H3 |
Three of eight survived contact with a real container, and two of those are the same bug on two frontend protocols. Five were wrong. That ratio is ordinary, and it is exactly why the controls matter so much: when most of your ideas are wrong, you need a test that can tell you which ones.
The bug
Three separate behaviours, each reasonable on its own.
1. Traefik forwards a CONNECT like any other request
Traefik's default proxy is Go's net/http/httputil.ReverseProxy over a shared
http.Transport. Here is buildSingleHostProxy in pkg/proxy/httputil/proxy.go at
v3.6.23, the tag everything in this post was measured against:
func buildSingleHostProxy(target *url.URL, passHostHeader bool, preservePath bool,
flushInterval time.Duration, roundTripper http.RoundTripper,
bufferPool httputil.BufferPool) http.Handler {
return &httputil.ReverseProxy{
Director: directorBuilder(target, passHostHeader, preservePath),
Transport: roundTripper,
FlushInterval: flushInterval,
BufferPool: bufferPool,
ErrorLog: stdlog.New(logs.NoLevel(log.Logger, zerolog.DebugLevel), "", 0),
ErrorHandler: ErrorHandler,
}
}
(By v3.7 the Director had become a Rewrite, which is the form the fix later wraps.)
There is no mention of CONNECT anywhere in it. A request with Method: CONNECT goes
to the backend transport exactly like a GET would, and its body is still a live
stream coming from the client.
An HTTP reverse proxy is not a forward proxy, so there is no reason for it to accept a
CONNECT in the first place. It simply never had a rule that said otherwise.
2. Go writes a CONNECT body with no framing
This is the part that turns a forwarded CONNECT into a smuggling primitive.
When net/http writes a request body of unknown length, it normally chunk-encodes it.
There is one exception, in net/http/transfer.go:
func (t *transferWriter) shouldSendChunkedRequestBody() bool {
// Note that t.ContentLength is the corrected content length
// from rr.outgoingLength, so 0 actually means zero, not unknown.
if t.ContentLength >= 0 || t.Body == nil { // redundant checks; caller did them
return false
}
if t.Method == "CONNECT" {
return false
}
...
}
For a CONNECT, no chunking. And because the length is unknown, no Content-Length
either. The bytes go onto the socket raw, right after the header block. For a real tunnel that
is correct behaviour, because those bytes are TLS records and HTTP has no business
touching them. The problem is that Traefik gets here without a tunnel ever having been
opened.
Go even goes out of its way to push them out promptly. A few lines further down,
CONNECT is the only method that gets a flushing writer:
} else if t.ContentLength == -1 {
dst := w
if t.Method == "CONNECT" {
dst = bufioFlushWriter{dst}
}
ncopy, err = t.doBodyCopy(dst, body)
}
Here is what that looks like on the wire. I captured this by pointing Traefik at a raw TCP listener that just prints what it receives.

The vulnerable build is the first of the three. There is no Content-Length and no
Transfer-Encoding, and then a complete HTTP/1.1 request sitting where a tunnel payload
would be. With no framing to tell it otherwise, the backend has no reason to read those
bytes as a body, so it reads them as the next request on the connection.
3. The pool takes the socket back
The backend answers the CONNECT with a 405, keeps the connection alive because
that is the HTTP/1.1 default, and reads the trailing bytes as a pipelined request. It
now owes a response that nobody is waiting for.
Traefik's transport reads the 405, sees a complete response on a healthy connection,
and files the socket away for reuse. Every check it makes says the socket is fine:

The last one is the one that decides it.
net/http will not reuse a connection until the request it wrote has finished writing.
For a CONNECT, the body is the tunnel, so "finished" means the client closed its half.
On HTTP/2 and HTTP/3 the client does exactly that. END_STREAM half-closes the stream
while the connection stays open, so the forwarded body reaches EOF, the check passes,
and the dirty socket goes back in the pool.
An HTTP/1.1 frontend is not affected, but not because of this check. It is not affected
because there is never an unknown-length body to write in the first place. Go's HTTP/1
server gives a request with no Content-Length and no chunked Transfer-Encoding an
empty body:
if isRequest {
// ... all other request methods are assumed to have
// no body if neither Transfer-Encoding chunked nor a
// Content-Length are set.
return 0, nil
}
So an H1 CONNECT followed by raw bytes is forwarded with no body at all, and the
trailing bytes stay on the attacker's own connection, where Traefik reads them as the
attacker's next request. If the attacker does declare a length or chunk the body, that
framing is preserved on the way out. Either way the backend socket still goes back in
the pool. It is just clean.
So the attacker needs an HTTP/2 or HTTP/3 frontend, which is what you get the moment you turn on TLS.
Putting it together

The attacker's view of all this is a 405 Method Not Allowed. That is it. They send
three frames, get a boring error, and hang up. The interesting part happens to somebody
who never met them.
Why some backends and not others
Traefik writes the CONNECT and the smuggled bytes no matter what. Whether anything
crosses is decided at the other end, by how the backend answers. So I probed the usual
suspects with a small script. This table is about one thing only: how a server answers a
CONNECT aimed at it.
| backend | server | CONNECT / |
CONNECT host:port |
|---|---|---|---|
mccutchen/go-httpbin |
Go net/http | armed, 405 keep-alive | armed, 405 keep-alive |
traefik/whoami |
Go net/http | armed, 200 keep-alive | armed, 404 keep-alive |
kennethreitz/httpbin |
gunicorn / Flask | armed, 405 keep-alive | armed, 404 keep-alive |
httpd:2.4 |
Apache 2.4.68 | safe, 400 + close | armed, 405 keep-alive |
nginx:alpine |
nginx 1.31.4 | safe, 400 + close | safe, 405 + close |
tomcat:10 |
Tomcat | safe, 501 + close | safe, 501 + close |
node:22 http |
Node.js stdlib | safe, no response at all | safe, no response at all |
python -m http.server |
Python stdlib | safe, 501 + close | safe, 501 + close |
Measured 2026-08-20 against current stock images. CONNECT / is what Traefik emits by
default; CONNECT host:port is what it emits with sanitizePath off. Armed means the
backend kept the socket alive and read the trailing unframed bytes as a second
request, which is the condition the whole bug rests on.
Two things stand out.
A 200 is not safer than a 405. traefik/whoami answers a proxied CONNECT with a
keep-alive 200, and it is armed exactly like the backends that answer 405. Go's client only treats
101 as a real protocol switch. A 200 answer to a CONNECT is read as an ordinary
response and the socket is pooled just the same.
So the rule is not "a keep-alive non-2xx", which is how I described it in my own report.
It is a keep-alive response that does not drain the body, and that is
self-delimiting. That last part matters: if the backend's answer carries neither
Content-Length nor chunked Transfer-Encoding, Go treats the body as running to end
of connection and refuses to reuse the socket anyway. Getting your backend to answer 2xx
is not a mitigation.
The two request-targets are the same bug, aimed differently. Which one your backend sees is decided by a Traefik feature that has nothing to do with CONNECT, and it is worth explaining because it is easy to mistake for a defence.
Entry points have a sanitizePath option, on by default. Its job is what the name says,
and the comment above it in the source is explicit: it "removes the .., . and
duplicate slash segments from the URL according to RFC 3986". It exists for path
traversal and route matching. It was not written with tunnels in mind, and it makes no
claim about them.
It does its job by running every request URL through JoinPath():
// Cleans the URL raw path and path.
r2.URL = r2.URL.JoinPath()
A CONNECT has no path, and JoinPath() on an empty path gives you /. So a
side effect of a path-cleaning feature is that Traefik emits CONNECT / where it would
otherwise have emitted authority-form CONNECT host:port. Stock Apache treats those two
very differently: it closes the connection on the first and keeps it alive on the second.
That is worth knowing if you go and test this yourself, because flipping sanitizePath
flips the Apache result and it can look like you have found a mitigation. You have not,
because it never removes the body. All it changes is which backends happen to be armed,
and Apache is the only one in the table that moves at all. The Go and Python backends
are armed either way.
What it takes to reproduce
I am not shipping a turn-key exploit script here. Everything you need is below: the configuration, the three frames, and what to look for. If you want to see it for yourself, build it.
The whole vulnerable configuration is stock. Nothing about CONNECT, transports or
paths is set anywhere:
# traefik.yml
entryPoints:
websecure:
address: ":443"
http3:
advertisedPort: 443
providers:
file:
directory: /etc/traefik/dynamic
watch: true
# dynamic config
http:
routers:
r1:
rule: "Host(`traefik.local`)"
entryPoints: [websecure]
service: httpbin
tls: {}
services:
httpbin:
loadBalancer:
servers:
- url: "http://httpbin-backend:8080"
A TLS entry point and a backend. That is the vulnerable config. (My routing files also
set passHostHeader: false, which is not the default. It changes the authority Traefik
puts on the forwarded CONNECT, not whether the smuggle works.)
The attack is three frames
The client is hand-rolled because a normal HTTP library will not let you do this. It writes HTTP/2 frames directly:
// 1. HEADERS: a plain CONNECT. No :scheme, no :path, exactly as RFC 9113 requires.
// EndStream is false, because we still have something to say.
fields := [][2]string{{":method", "CONNECT"}, {":authority", auth}}
c.fr.WriteHeaders(http2.HeadersFrameParam{
StreamID: sid, BlockFragment: encodeHeaders(fields), EndHeaders: true, EndStream: false,
})
// 2. DATA: the payload. A whole HTTP/1.1 request.
c.fr.WriteData(sid, false, payload)
// 3. DATA: empty, END_STREAM. This is what arms the pool.
c.fr.WriteData(sid, true, nil)
The payload is a plain text request aimed at a slow endpoint, so its response is still being generated when the socket goes back in the pool:
GET /delay/2?tag=ATTACKER_SMUGGLED HTTP/1.1
Host: traefik.local
X-Smuggled: poisoned-by-a-stranger
The victim is a separate container, on its own TLS connection, asking for something completely unrelated:
:method GET :scheme https :authority traefik.local
:path /get?tag=VICTIM_OWN
What the victim gets

The victim asked for one thing and got the answer to another. The reply carries the attacker's query string and the attacker's custom header, and the victim's own request is nowhere in it.
In the lab the smuggled request is /delay/2, which is harmless. In a real deployment
the attacker picks the endpoint, and the timing. Whatever that backend would return
is what the next user reads.
What I tested
The harness is deliberately unremarkable: the official Traefik image, stock config, a real off-the-shelf backend behind it, and two client containers so the attacker and the victim never share a connection, a process or an IP. The clients are the hand-written frame writer above, plus a quic-go equivalent for HTTP/3. Then a scenario matrix, run against the vulnerable release and the patched one.
I re-ran the whole thing while writing this post, on 2026-08-20:
| scenario | what it is | v3.6.23 | v3.7.9 |
|---|---|---|---|
core-go-h2 |
stock config, Go backend, H2 to H2 | CROSS | NO_CROSS |
core-go-h3 |
stock config, Go backend, H3 to H3 | CROSS | NO_CROSS |
core-go-x |
stock config, Go backend, H2 to H3 | CROSS | NO_CROSS |
core-py-h2 |
stock config, gunicorn/Flask, H2 | CROSS | NO_CROSS |
core-py-h3 |
stock config, gunicorn/Flask, H3 | CROSS | NO_CROSS |
mech-ap-h2 |
sanitizePath off, Apache, H2 | CROSS | NO_CROSS |
mech-ap-h3 |
sanitizePath off, Apache, H3 | CROSS | NO_CROSS |
ctl-apache |
control: Apache closes CONNECT / |
NO_CROSS | NO_CROSS |
ctl-pooloff |
control: backend pool disabled | NO_CROSS | NO_CROSS |
ctl-kaoff |
control: backend KeepAlive Off |
NO_CROSS | NO_CROSS |
safe-fast |
FastProxy, chunk-frames the body | NO_CROSS | NO_CROSS |
cascade |
bounded pool, 8 sequential victims | 8 of 8 crossed | 0 of 8 |
CROSS means the victim received a response it never asked for.
The control rows are the point of the exercise, and they are the part worth copying if you build this yourself. A cross-user result means nothing unless you can also show the cases that must not cross:
- ctl-apache runs the default config against Apache, which closes
CONNECT /. - ctl-pooloff disables backend connection reuse (
maxIdleConnsPerHost: -1). - ctl-kaoff runs the backend with
KeepAlive Off.
All three stay clean. That is what rules out the boring explanations. The crossing depends on the backend socket being reused, rather than on HTTP pipelining in general or on the two clients sharing something they should not. Without those three rows there would be no way to rule out the duller explanations.
safe-fast is Traefik's experimental FastProxy, which chunk-frames the CONNECT
body, which you can see in the second of the three wire captures above: same bytes,
wrapped in a Transfer-Encoding: chunked frame. They arrive as body content and are
never read as a request. It does not cross.
How bad is it
The advisory scores it 7.0 High, and records the confidentiality and integrity impact against "subsequent systems" rather than against Traefik. That matches what actually happens. Traefik carries on working normally, and the damage shows up behind it, in whatever the backend was about to send someone.
Whether it lands depends on the pool. The default is 200 idle connections per backend,
so a poisoned socket is one of many and the attacker has to wait for a victim to be
routed onto it. Go has a guard for this case: when the read loop looks at a connection
that should be idle and finds bytes already sitting there, it logs Unsolicited response received on idle HTTP channel and closes it. The guard is conditional on there being no
request outstanding on that connection, so it only fires while the socket is genuinely
idle.
That is why a small pool is worse than a large one. With fewer sockets there is usually a request already waiting when the poisoned one is released, so it gets handed over straight away and the idle check never runs. I set the pool to one connection and sent eight victims through in sequence:

The result is not random. Victim 1 receives the attacker's smuggled response, victim 2 receives victim 1's, victim 3 receives victim 2's, and so on to the end of the run. The socket stays exactly one response behind for as long as it remains in service, so everyone routed onto it reads the previous user's reply.
A note on this number. My first cascade counter was broken. The test for "did this victim get its own response" could never match, because go-httpbin pretty-prints its JSON with more whitespace than my pattern allowed, so every victim fell through to the cross-user branch instead. What gave it away was the same counter reporting 8 of 8 poisoned against the patched build, which cannot happen. The numbers above come from a rewritten classifier that records the tag each victim actually received. The original 8 of 8 was correct, but I had not measured it correctly.
ForwardAuth, the second door
The same primitive shows up in the ForwardAuth middleware.
With forwardBody: true and preserveRequestMethod: true, ForwardAuth rebuilds the
request and sends it to your auth server. It buffers the body and reattaches it:
forwardReq, err := http.NewRequestWithContext(req.Context(), forwardReqMethod, fa.address, nil)
...
if bodyBytes != nil {
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
forwardReq.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
The body is attached but ContentLength is never set. http.NewRequestWithContext was
called with a nil body, so it left ContentLength at 0. Swapping a real body in
afterwards does not update it, and to Go a non-nil body with ContentLength 0 means
"unknown length". Combined with the CONNECT rule from earlier, the auth client writes that body
unframed too. I captured it doing so.
I want to be straight about how far I took this one. I confirmed the mechanism: the bytes go out unframed to the auth server over a shared client pool. I did not stage an end-to-end victim crossing through the auth path. It is a confirmed vehicle, not a demonstrated exploit, and the fix treats it as such.
The spec saw this coming
While writing this up I went looking for what the standards say, and found something better than I expected.
RFC 9931, "Security Considerations for Optimistic Protocol Transitions in HTTP/1.1", was published in March 2026, four months before I reported this. Section 8 is titled "Requirements for HTTP CONNECT" and says:
Proxy clients that send CONNECT requests on behalf of untrusted TCP clients MUST do one or both of the following:
- Wait for a 2xx (Successful) response before forwarding any TCP payload data.
- Send a "Connection: close" request header.
Proxy clients that don't implement at least one of these two behaviors are vulnerable to a trivial Request Smuggling attack.
And then, plainly:
At the time of writing, some proxy clients are believed to be vulnerable as described.
Traefik was one of them. So the bug had a name, a spec section, and a published mitigation before anyone reported it. That gap between "the RFC says do this" and "the code does this" is where a lot of these live.
The fix
Traefik's response was quick, and what they shipped is more thorough than what I suggested. Instead of picking one of RFC 9931's two options, they implemented both, plus a third change for the side doors.

Layer 1: hold the payload back
A new connectHandler wraps the reverse proxy. For a CONNECT with an unknown-length
body it replaces req.Body with a bodyDeferrer whose Read blocks on a channel:
func (bd *bodyDeferrer) Read(p []byte) (n int, err error) {
select {
case <-bd.releaseCh:
// already released
default:
select {
case <-bd.doneCh:
return 0, context.Canceled
case <-bd.releaseCh:
}
}
return bd.body.Read(p)
}
The transport writes the header block and then stalls on that Read, so nothing else
reaches the backend. The response writer decides what happens next, once the status is
known:
func (w *connectResponseWriter) WriteHeader(statusCode int) {
// The tunnel was refused, so the request body never becomes tunnel data
// and must not reach the backend.
if statusCode/100 != 2 {
_ = w.bodyDeferrer.Close()
} else {
w.bodyDeferrer.Release()
}
w.ResponseWriter.WriteHeader(statusCode)
}
On a 2xx the bytes are released and become tunnel data, which is what they were always supposed to be. On anything else the body is closed and they are dropped. That is RFC 9931's option 1.
Layer 2: never pool a CONNECT socket
This is the one I like. In the request rewriter:
// Adding the "Connection: close" header to the request ensures that we are not
// reusing the connection for subsequent requests in case the backend does not
// support CONNECT and returns a 2xx response.
if pr.Out.Method == http.MethodConnect {
pr.Out.Close = true
}
Three lines, and the whole class of problem goes away. Connection: close means
net/http will not reuse that socket regardless of what the backend answers.
The comment tells you why they did not stop at layer 1. Layer 1 keys on the status
code, and a backend that answers CONNECT with a keep-alive 2xx it never meant as a
tunnel would slip past it, which is exactly what traefik/whoami does. Layer 2 does not
care what the backend said. It is RFC 9931's option 2, and it covers the case
the first one misses.
Both layers show up on the wire in the last of the three captures near the top of this
post: Connection: close in the headers, and nothing at all after them.
Layer 3: the side doors
ForwardAuth no longer forwards the body of a CONNECT with an unknown length, because
those bytes are tunnel data and not a request body. When it does forward a body, it now
sets the framing properly:
forwardBody := fa.forwardBody
// When a CONNECT method has a body with an unknown length we consider the bytes as
// tunnel data. Therefore, we do not want to forward them to the auth server.
if req.Method == http.MethodConnect && req.ContentLength < 0 {
forwardBody = false
}
if forwardBody {
forwardReq.ContentLength = req.ContentLength
forwardReq.TransferEncoding = req.TransferEncoding
...
}
And FastProxy, which never supported tunnelling in the first place, now says so:
// The FastProxy does not support CONNECT tunneling,
// so any CONNECT request is rejected.
if req.Method == http.MethodConnect {
rw.WriteHeader(http.StatusNotImplemented)
return
}
One behaviour change before you upgrade
There is a migration note worth reading. An HTTP/1 CONNECT now gets
501 Not Implemented. It never worked before either, so nothing that used to function
stops working; the difference is that the failure is now explicit rather than silent.
What my suggested patch got wrong
I sent a patch with the report. It was cruder than what shipped, and one part of it was plain wrong. Since the point of writing these up is that other people learn something, here it is:
if req.Method == http.MethodConnect && req.Header.Get(":protocol") == "" {
rw.WriteHeader(http.StatusMethodNotAllowed)
return
}
The idea was to reject plain CONNECT while letting RFC 8441 extended CONNECT
through, since extended CONNECT carries a :protocol pseudo-header and is how
WebSockets ride on HTTP/2.
That works on HTTP/2, where Go's server puts :protocol into the header map. It does
not work on HTTP/3. quic-go parses :protocol into req.Proto and never into
req.Header, so on an HTTP/3 entry point my check sees an empty string every time and
rejects legitimate extended CONNECT along with the bad kind. I would have rejected
every extended CONNECT on an HTTP/3 entry point to fix a smuggling bug. Traefik does
not implement RFC 8441 today, so nothing would have broken in practice, but the guard
was wrong for a reason that had nothing to do with the bug.
The lesson is not subtle. A guard that reads a pseudo-header directly is making an
assumption about which server parsed the request, and on a proxy that speaks three
protocols that assumption does not hold. Traefik's fix avoids the whole question by
never keying on plain-versus-extended at all. It defers the payload for any CONNECT
and releases it on 2xx, which is correct for both kinds.
If you run Traefik
Upgrade. v2.11.53, v3.6.24, or v3.7.9.
Affected versions are everything at or below v2.11.52, v3.0.0 through v3.6.23, and v3.7.0 through v3.7.8.
If you cannot upgrade today, the one workaround that works is turning off backend connection reuse. Put it in the static configuration, at the top level:
# traefik.yml
serversTransport:
maxIdleConnsPerHost: -1
That rebuilds the default@internal transport, which is what every service uses when
it does not name one, so it covers the whole instance.
Be careful with the dynamic form, because it is easy to write a version that silently does nothing:
# this on its own is a NO-OP
http:
serversTransports:
no-reuse:
maxIdleConnsPerHost: -1
A serversTransports entry only applies to services that reference it by name. A
service that omits serversTransport keeps using default@internal at the default 200
idle connections per host, and Traefik logs no error. So the dynamic form has to be
repeated on every service:
http:
services:
my-service:
loadBalancer:
serversTransport: no-reuse
servers: [...]
I measured all three against traefik:v3.6.23:
baseline, no workaround CROSS
dynamic serversTransports, NOT referenced CROSS
static serversTransport (top-level) NO_CROSS
dynamic + service references it NO_CROSS
You will pay for it in latency, because every request now dials a fresh backend connection. Treat it as something to do until you can deploy the patch.
Things that are not workarounds:
sanitizePath: true. It is on by default already, and it is a path-cleaning feature, not a CONNECT guard. It only decides which backends are armed.- Getting your backend to answer
CONNECTwith a 2xx. That is armed too. - Turning off HTTP/3 but leaving HTTP/2 on. Both work.
Disclosure timeline
| Date | Event |
|---|---|
| 2026-07-13 | Reported privately via GitHub Security Advisory GHSA-3ccp-42pg-hgv6 |
| 2026-07-21 | Traefik confirmed the report and started on a fix |
| 2026-07-22 | #13542 defer the CONNECT payload, #13543 ForwardAuth and FastProxy |
| 2026-07-23 | #13556 do not pool CONNECT connections, #13554 migration note |
| 2026-07-24 | v2.11.53, v3.6.24 and v3.7.9 released |
| 2026-07-27 | Advisory published |
| 2026-08-06 | CVE-2026-71324 assigned, advisory databases updated |
| 2026-08-21 | This post |
Eleven days from report to shipped fix, across three release branches. Credit where it is due: that is a good response, and the fix they wrote is better than the one I proposed.
More to come
The same run of research turned up bugs in other proxies. Those are still with their vendors, and some of the products are not patched yet, so I am not naming them or describing them here. As each one ships a fix I will write it up the same way: the mechanism, the wire captures, enough to rebuild it, and the parts I got wrong.
In the meantime, if you run a proxy in front of anything, the question worth asking is the one in this post: what does your origin do when it is handed a method it does not support, and does it keep the connection afterwards.
References
- GHSA-3ccp-42pg-hgv6 and CVE-2026-71324
- RFC 9931, Security Considerations for Optimistic Protocol Transitions in HTTP/1.1, section 8
- RFC 9110 section 9.3.6, CONNECT
- RFC 9112 section 11.2, Request Smuggling
- RFC 9113 section 8.5, The CONNECT Method (HTTP/2)
- RFC 9114 section 4.4, The CONNECT Method (HTTP/3)
- RFC 8441, Bootstrapping WebSockets with HTTP/2
- Linhart, Klein, Heled, Orrin, "HTTP Request Smuggling", Watchfire, 2005
- James Kettle, "HTTP Desync Attacks: Request Smuggling Reborn", 2019
- James Kettle, "HTTP/2: The Sequel is Always Worse", 2021
- James Kettle, "Browser-Powered Desync Attacks", 2022
- Jake Miller, "h2c Smuggling: Request Smuggling Via HTTP/2 Cleartext", Bishop Fox, 2020
- CVE-2026-58055, nghttpx forwarding an upgrade request onto reusable keep-alive backend connections. The closest prior art to this bug.
- CVE-2026-58157, Apache Traffic Server reusing server sessions and tunnels across client connections
- CVE-2026-2833, Pingora forwarding bytes after an Upgrade before the 101 arrives
- xclow3n, "Breaking Pingora", 2026