Go Back

HTTP/2 CONNECT Smuggling into Traefik's Connection Pool

An engraving of a castle courtyard. A hooded figure tips a small vial into the communal well while townspeople draw water from the same well and carry it away.

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 CONNECT to the backend as if it were an ordinary request, body and all. Go's net/http writes a CONNECT body 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 CONNECT with something like 405, 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.Transport unmodified?
  • 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.

Three wire captures. The vulnerable build writes the CONNECT header block followed immediately by a raw GET request. FastProxy writes the same bytes with Transfer-Encoding: chunked. The patched build writes the header block with Connection: close and nothing after it.

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:

A list of the five conditions net/http checks before reusing a connection. Four of them pass trivially. The fifth, whether the request body reached EOF, is the one that decides it. HTTP/2 and HTTP/3 frontends pass it and are affected. HTTP/1.1 frontends are not affected, for a different reason: Go's HTTP/1 server gives such a request an empty body, so there is nothing to write raw.

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

A sequence diagram across four lanes: attacker, Traefik, backend socket, victim. The attacker sends CONNECT, a DATA frame containing an HTTP request, and END_STREAM. Traefik forwards it raw. The backend answers 405 and queues the smuggled request. Traefik pools the socket. A victim on a separate connection reuses it and receives the attacker's response.

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 sent a GET for /get?tag=VICTIM_OWN and received a 200 whose body is the response to /delay/2?tag=ATTACKER_SMUGGLED, including the X-Smuggled header the attacker set.

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:

Two columns comparing traefik:v3.6.23 and v3.7.9 with the pool bounded to one connection. On the vulnerable build victim 1 gets the attacker's smuggled response and victims 2 through 8 each get the previous victim's response. On the patched build all 8 get their own.

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:

  1. Wait for a 2xx (Successful) response before forwarding any TCP payload data.
  2. 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.

Three cards describing the shipped fix: defer the payload until the backend accepts the tunnel; never put a CONNECT connection back in the pool; and close the ForwardAuth and FastProxy side doors. Plus a note that HTTP/1 CONNECT now returns 501.

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 CONNECT with 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