Overview
| Item | Detail |
|---|---|
| CVE ID | CVE-2026-88008 |
| Affected product | Traefik (open-source reverse proxy and load balancer) |
| Affected versions | 2.11.26 through 2.11.56, 3.4.2 through 3.7.12 |
| Fixed in | 2.11.57, 3.7.13 |
| CVSS | 7.0 (High, CVSS v4.0) — some advisories also cite 9.1 under CVSS 3.1 |
| Type | CWE-444 (Inconsistent Interpretation of HTTP Requests / HTTP Smuggling), CWE-863 (Incorrect Authorization) |
| Authentication required | None |
| Reported by | ihopenre-eng, via Traefik's GitHub Security Advisory (GHSA-w4v4-9rw7-5326), disclosed 2026-09-07 |
| Precondition | An attacker-reachable "unprotected router" and a "protected router" must share the same backend, and that backend must respond with 101 to an Upgrade: h2c request |
Traefik's model is simple: a router decides which backend a request goes to, and the middleware attached to that router — BasicAuth, ForwardAuth, IPAllowList, RateLimit, and the like — handles authentication and authorization. For this model to actually be secure, one assumption has to hold without exception: every request has to pass through the router and its middleware, no detours.
CVE-2026-88008 is exactly where that assumption breaks. Since Go 1.24, Traefik supports unencrypted HTTP/2 with prior knowledge (h2c), and that created a path where a client-supplied Connection: Upgrade and Upgrade: h2c header gets forwarded to the backend with no validation at all. If the backend accepts that upgrade request and responds with 101 Switching Protocols, Traefik stops interpreting the connection as HTTP requests entirely and switches it into a raw tunnel that just copies bytes between the client and the backend. The problem is that routing and middleware selection only happen for the first HTTP/1 request. Once the tunnel opens, every HTTP/2 request that flows through it afterward completely skips Traefik's router and middleware chain.
The upshot: an attacker only needs to open an h2c tunnel through a completely unauthenticated public path (/public) to reach a BasicAuth-protected path (/admin) on the same backend — with zero credentials.
Where the flaw lives — four points the Upgrade header passes through
The GitHub security advisory identifies four places in the code involved in this flow, based on the default proxy implementation, pkg/proxy/httputil.
┌───────────────┐ GET /public ┌──────────┐
│ │ Connection: Upgrade │ │
│ Attacker │──Upgrade: h2c──────────────▶│ Traefik │
│ │ │ │
└───────────────┘ └────┬─────┘
│ r-public (no auth)
▼
┌──────────┐
│ Backend │
└────┬─────┘
│ 101 Switching Protocols
▼
[ raw byte tunnel ]
│
│ HTTP/2 GET /admin
▼
┌──────────┐
│ Backend │──▶ /admin (BasicAuth never applied)
└──────────┘
Step-by-step source analysis
Step 1 — there's no allowlist on the Upgrade header
removeConnectionHeaders() in pkg/middlewares/forwardedheaders/forwarded_header.go (around lines 198–234) cleans up the hop-by-hop headers listed in a Connection header. But when Connection: Upgrade is present, the Upgrade header itself is treated as something to preserve, not something to strip. The problem: nothing here checks whether that Upgrade value is websocket, h2c, or any arbitrary token an attacker made up.
// pkg/middlewares/forwardedheaders/forwarded_header.go (removeConnectionHeaders, ~198-234)
// when Connection: Upgrade is present, the Upgrade header is preserved and passed on unchanged
// — there's no check for whether the token is "websocket"
Step 2 — WebSocket handling is "special treatment," not an allowlist
isWebSocketUpgrade() in pkg/proxy/httputil/proxy.go (around line 170) applies cleanWebSocketHeaders() specifically to WebSocket upgrade requests. At first glance this looks like logic that "allows WebSocket and blocks everything else," but in practice it only adds extra handling for WebSocket — there's no code anywhere that rejects other upgrade tokens, h2c included. If isWebSocketUpgrade() returns false, the request simply continues on as an ordinary upgrade request.
Step 3 — smart_roundtripper forwards the upgrade request to the backend over HTTP/1
RoundTrip() in pkg/server/service/smart_roundtripper.go (around line 56) sends a request containing Connection: Upgrade to the backend over HTTP/1. Up to this point, all that's happened is "the request got forwarded" — the actual security boundary collapses in the next step.
Step 4 — a 101 Switching Protocols response erases the routing/middleware layer entirely (the root cause)
Traefik's default proxy uses the standard library's net/http/httputil.ReverseProxy. When the backend responds with 101 Switching Protocols, ReverseProxy switches that connection into a tunnel mode that just copies bytes between client and backend. On its own, this is standard behavior meant to support proxying WebSocket — nothing wrong with it in isolation.
The actual problem is that Traefik's router and middleware selection happen only for that one initial HTTP/1 request. Once the 101 response opens the tunnel, Traefik no longer treats that connection as "a flow of HTTP requests it needs to interpret." So when a new HTTP/2 stream (i.e., a brand-new HTTP request) arrives over that tunnel, it is never routed from scratch, and no middleware ever touches it.
// Core of the PoC attacker code (published as GHSA-w4v4-9rw7-5326)
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",
))
// ... after receiving the 101, an HTTP/2 client is built on top of the SAME raw socket
cc, _ := (&http2.Transport{}).NewClientConn(raw)
req, _ := http.NewRequest("GET", "http://x/admin", nil)
r2, _ := cc.RoundTrip(req) // /admin never passes through the r-admin router at all
The key point: the attacker never sends a request to /admin directly. They turn the connection established through the unauthenticated /public router into a tunnel, and then, over that tunnel — the same already-open TCP connection — they issue a brand-new HTTP/2 request that reaches /admin. As far as Traefik is concerned, it processed exactly one /public request; whatever flows over that same connection afterward is invisible to it.
What the published PoC actually demonstrated
With Traefik configured so that /public (no auth) and /admin (BasicAuth) both route to the same backend, the researcher's published PoC produced:
[1] Direct GET /admin (no creds) -> 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)"
/admin, which returns 401 when requested directly, returns 200 with the protected data once reached through the h2c tunnel. BasicAuth, ForwardAuth, IPAllowList, RateLimit — every middleware attached to that router — gets bypassed, and requests sent through the tunnel don't show up as individual requests in Traefik's normal access logs, metrics, or tracing.
Why this is different from operator-configured h2c support
Traefik already lets operators explicitly enable h2c backend communication through the h2c:// service scheme or the transportH2C setting. This vulnerability is unrelated to that feature. Here, the upgrade is initiated by the client, not the operator, and Traefik forwards it regardless of whether h2c is configured for that backend at all. In other words, even a completely ordinary HTTP/1 backend that the operator never intended to speak h2c can be affected, as long as its implementation happens to accept Upgrade: h2c and answer with 101.
Not every backend is vulnerable, though. Recent golang.org/x/net/http2/h2c implementations no longer support the HTTP/1.1 upgrade mechanism at all, so a current Go-based h2c server using that implementation isn't exposed through this path. Older implementations, non-Go servers, custom h2c handlers, and some gRPC-adjacent stacks, however, may still accept the upgrade.
The patch — stop forwarding the Upgrade token unchecked
Traefik 2.11.57 and 3.7.13 stop forwarding the Upgrade: h2c token and its accompanying HTTP2-Settings header to the backend. Upgrade: websocket is unaffected and continues to work as before.
The principle here is clear. For any component that has to guarantee requests pass through a specific layer — routing, authentication — before reaching their destination, protocol-switching mechanisms that can bypass that layer (Upgrade, CONNECT, and similar) need to be restricted to an explicit allowlist. The key lesson of this case is that "we added special handling for WebSocket" does not mean "anything that isn't WebSocket gets rejected." And any code that handles a response like 101 Switching Protocols — one that fundamentally redefines what the connection even is — needs to re-verify that its security layer still applies after that transition, not just before it.
Remediation
- Update Traefik to 2.11.57 or 3.7.13 or later immediately. (The 3.4.2–3.6 branches are end-of-life, so migrate to 3.7.13.)
- If an immediate update isn't possible, separating services so that protected and public paths never share the same backend is a workable interim mitigation — if they're different backends, an opened tunnel still can't reach the protected path.
- Check directly whether your backend actually accepts
Upgrade: h2cand responds with101— if it doesn't, this precondition doesn't hold for your environment. - Even after patching, requests sent through the tunnel never appeared as individual entries in normal access logs, so confirming whether this was exploited during a past exposure window can't rely on access logs alone. Cross-check backend-side logs (where available) and look for unusual long-lived connections or upgrade-request patterns.


Top comments (0)