Slipping an extra request through Cloudflare Pingora on a close-delimited body
- Identifier
- CVE-2026-2835
- Software
- Cloudflare Pingora
- Affected
- pingora-core <= 0.7.0
- Fixed in
- 0.8.0, released 2 March 2026
- Reported by
- Rajat Raghav (xclow3n)
- Disclosed
- 05 March 2026
This is the whole payload. No encoding tricks, nothing malformed enough to catch your eye in a log:
GET / HTTP/1.0
Host: target.com
Connection: keep-alive
Transfer-Encoding: identity, chunked
Content-Length: 29
0
GET /admin HTTP/1.1
X: Sent through a reverse proxy built on Pingora, the proxy sees one request for /. The Node backend behind it sees two: a request for /, and then a request for /admin that the proxy never inspected and never applied any of its rules to. The /admin request sits in the backend's buffer, waiting for the next thing to arrive on that connection. That will be some other user's request.
Rajat Raghav, who publishes as xclow3n, reported it through the Cloudflare Bug Bounty Program in January
- It was one of three smuggling bugs and a cache poisoning issue he found in the framework between
December 2025 and January 2026. The advisory is GHSA-hj7x-879w-vrp7 (CVE-2026-2835), scored 9.3 Critical. Cloudflare's engineering write-up is by Edward Wang, Fei Deng and Andrew Hauck.
Every source says this up front, and it is the part most likely to get garbled in retelling. Cloudflare's own CDN was not affected, and not because it got lucky. Pingora isn't used as an ingress proxy in Cloudflare's network. The layers that do face the internet forward to Pingora as HTTP/1.1 only, reject ambiguous framing, and normalise chunked requests down to a single Transfer-Encoding: chunked header. The bug bites people who took the open source framework and put it at the edge of their own network. That is what the framework is for.
Message boundaries#
Request smuggling needs two parties who disagree about message boundaries. HTTP/1.1 gives you two ways to say how long a body is, and the disagreements live in the gap between them.
Content-Length: 29 says the body is the next 29 bytes. Transfer-Encoding: chunked says the body arrives as a series of length-prefixed chunks, ending with a chunk of length zero. If both headers are present, chunked wins and the Content-Length is supposed to be dropped. Raghav's summary of why any of this matters: "The attacker sends a single payload that the proxy sees as one request, but the backend interprets as two. The second 'smuggled' request sits in the backend's buffer and gets prepended to the next legitimate user's request."
That last clause is the impact. You aren't attacking yourself. Raghav proved cross-user exploitation with two VPS instances on different IPs, one sending the payload and the other sending an ordinary request. The backend logs showed /admin and /secret being served to the proxy's internal IP, for requests the proxy had never seen.
Parsing Transfer-Encoding#
Pingora had a function called is_chunked_encoding() that decided whether a request body was chunked. It did an exact string comparison against "chunked".
RFC 9112 allows Transfer-Encoding to be a comma-separated list, and it's the last encoding in the list that determines framing. identity, chunked is a chunked body. Pingora's exact match against that string fails, so Pingora concluded the request wasn't chunked.
The sibling bug was reported five days later and folded into the same CVE. It is .get() versus .get_all(). HTTP says repeated headers with the same name are equivalent to one header with the values joined by commas. So these are the same message:
Transfer-Encoding: identity, chunkedTransfer-Encoding: identity
Transfer-Encoding: chunkedPingora fetched the header with .get(), which returns the first one. It compared identity against chunked, found no match, and reached the same wrong conclusion by a different route. Node, Express, Fastify and friends read all the values and frame the body as chunked.
Not a CL.TE desync#
Everyone's first instinct, including the reporter's, is to call this a classic CL.TE desync: proxy uses Content-Length, backend uses Transfer-Encoding. That's not what happens, and the correction is more interesting than the original claim.
Pingora handled the precedence rule correctly. Transfer-Encoding was present, so it stripped Content-Length, which is right regardless of whether the encoding value was one it understood. But it hadn't recognised the encoding as chunked either. So now the request had no framing at all that Pingora knew about, and it had to decide how long the body was some other way.
The way it decided, for HTTP/1.0 requests, was to read until the connection closes. That mode is called close-delimited, and it's completely normal for responses. It's how HTTP/1.0 servers returned bodies of unknown length before chunked encoding existed. It is never legal for a request. The Cloudflare post puts the distinction plainly: "Although response bodies are allowed to be close-delimited, request bodies are never close-delimited. In fact, this clarification is now explicitly called out as a separate note in RFC 9112."
In close-delimited mode Pingora stops parsing and forwards bytes. Everything after that first request header becomes body. That includes the pipelined GET /admin, which arrives at the backend intact.
Raghav's chain:
TE misparsing > no framing recognized > illegal close-delimited request body > raw bytes forwarded > backend parses smuggled request
Two details make this worse than it first looks. The HTTP version is chosen by the attacker, so "only affects HTTP/1.0" is not a mitigating condition. It is part of the exploit. During triage a HackerOne analyst tested the report with HTTP/1.1, got a 400 back, and nearly wrote the bug off for exactly this reason. Cloudflare's own investigation found that the close-delimited path is reachable with no Transfer-Encoding games at all. An HTTP/1.0 request with no framing headers whatsoever triggers the same passthrough.
That mode is where the earlier reports lead too. The first bug Raghav filed, back in December, was a separate smuggling issue. Any Upgrade header put Pingora into raw-byte passthrough immediately, without waiting for the backend to answer 101 Switching Protocols. Different trigger, same destination: it calls the same internal function that puts the session into close-delimited mode.
The fix in 0.8.0#
Pingora 0.8.0 was released on 2 March 2026. It parses message-length headers per RFC 9112, and it refuses to treat request bodies as close-delimited under any circumstances. It rejects HTTP/1.0 requests carrying Transfer-Encoding and invalid Content-Length values, and it now rejects CONNECT by default. The advisory names the commits: 7f7166d, 40c3c1e and 87e2e2f. If you can't upgrade, the documented workaround is to reject anything that isn't plain HTTP/1.1 with unambiguous framing in your own request filter. That stops the connection being reused.
The CVE disagreement#
There's a disagreement in the write-ups, and I think Raghav has the better of it. He wanted the comma-separated-list bug and the duplicate-header bug tracked separately: different root causes, different code paths, and two distinct changes even within Cloudflare's own patch. Cloudflare grouped them under one CVE. Their grounds were that the real vulnerability was the passthrough state, and that both header bugs were two ways of arriving at it. That is reasonable, and it's how the fix is structured. His objection:
Request smuggling is never one component's fault, it exists at the parsing boundary between proxy and backend. The TE bugs are independently broken parsing behavior that widen attack surface against any backend that interprets TE differently from Pingora, regardless of the passthrough.
Both bugs make the proxy's idea of a message boundary differ from the backend's. Both still need Pingora to do something illegal with the body afterwards before anything moves. Cloudflare's patch fixes the comma-separated list and the duplicate header separately. The CVE list records one bug.