Overview
| Item | Detail |
|---|---|
| CVE ID | CVE-2026-29057 |
| Affected | Next.js (self-hosted deployments using rewrites() to proxy to an external backend) |
| Vulnerable versions |
>= 9.5.0, < 15.5.13 and >= 16.0.0-beta.0, < 16.1.7
|
| Patched versions |
15.5.13, 16.1.7
|
| CVSS 4.0 | 6.3 (Moderate) |
| CWE | CWE-444 (Inconsistent Interpretation of HTTP Requests — Request Smuggling) |
| Root cause | A logic bug in deleteLength(), part of the http-proxy library bundled inside Next.js |
Next.js lets you proxy specific paths to an external backend via rewrites() in next.config.js. Under the hood, this relies on a vendored copy of the http-proxy library. When forwarding DELETE/OPTIONS requests, that library had a bug that made the forwarded request's headers disagree with its actual body. The result: an attacker can hide a second HTTP request inside the body of a seemingly normal request, and have it land on an unintended backend route (internal APIs, admin endpoints, etc.).
1. The Full Attack Flow
The core issue is a mismatch between "the body length the header claims" and "the number of bytes actually sent."
- The attacker sends a
DELETE /rewrites/pocrequest withTransfer-Encoding: chunked, hiding an entire second request —GET /secret HTTP/1.1...— as plain text inside the chunked body. - Next.js correctly understands
Transfer-Encodingand decodes the body, then passes the request through an internal function calleddeleteLength()before forwarding it to the backend. - Due to a bug, that function attaches
Content-Length: 0and deletes theTransfer-Encodingheader — but the body bytes are still streamed through viapipe(). - An intermediate proxy or load balancer sees
Content-Length: 0and assumes the request ends there, parsing the remaining bytes as a brand-new, independent request. - The backend ends up receiving two requests:
DELETE /rewrites/pocandGET /secret.
2. Byte-Level Comparison — Vulnerable vs. Patched
Comparing the actual bytes sent on the wire for the same input makes the problem unmistakable.
The vulnerable version writes content-length: 0 in the header, then appends the raw text GET /secret ... right after it. From the next hop's perspective, this can only look like "one zero-length request, followed by a separate request that happened to arrive right after."
The patched version keeps Transfer-Encoding: chunked intact. Chunked encoding marks its own body boundary using chunk-size prefixes (like 2E in hex) and a terminating 0 chunk, so the string GET /secret ... is correctly treated as body data and never gets split into a separate request.
3. Source Code Analysis — The deleteLength() Bug
The real root cause sits in one function inside http-proxy@1.18.1, which Next.js bundles: lib/http-proxy/passes/web-incoming.js.
Before the patch
deleteLength: function deleteLength(req, res, options) {
if ((req.method === 'DELETE' || req.method === 'OPTIONS')
&& !req.headers['content-length']) {
req.headers['content-length'] = '0';
delete req.headers['transfer-encoding'];
}
},
After the patch
deleteLength: function deleteLength(req, res, options) {
if ((req.method === 'DELETE' || req.method === 'OPTIONS')
&& typeof req.headers['content-length'] === 'undefined'
&& typeof req.headers['transfer-encoding'] === 'undefined') {
req.headers['content-length'] = '0';
}
},
In plain terms:
- This function exists because
DELETEandOPTIONSrequests typically have no body, so it tries to explicitly mark that withContent-Length: 0. -
Before the patch: the only condition checked is
!req.headers['content-length']— "if there's no Content-Length header." But aTransfer-Encoding: chunkedrequest is supposed to have no Content-Length header; that's normal. So this condition evaluates to true for chunked requests too, forcibly settingContent-Length: 0and then deleting theTransfer-Encodingheader. The problem is that only the header gets removed — the chunked body data has already entered the stream and is forwarded regardless. The result is a contradiction: the header says "no body," while a body is sent anyway. -
After the patch: the condition now requires that both Content-Length and Transfer-Encoding be absent (
typeof ... === 'undefined' && typeof ... === 'undefined'). IfTransfer-Encoding: chunkedis present, this condition is false, so the function does nothing and leaves the headers untouched. Node.js then re-encodes and sends the request with chunked framing intact, keeping the body boundary unambiguous.
Here's the side-by-side flow:
One-line summary: "Content-Length is absent" and "there is no body" are not the same thing — the pre-patch code conflated them. Any request using Transfer-Encoding to express its body length will naturally have no Content-Length header.
4. Additional Hardening — Connection Header Handling (common.js)
The fix didn't stop at web-incoming.js. The same commit also hardens setupOutgoing() in lib/http-proxy/common.js, closing off a connection-reuse bypass.
// Added logic
var hasTransferEncodingHeader = Object.keys(outgoing.headers).some(function (header) {
return header.toLowerCase() === 'transfer-encoding'
&& typeof outgoing.headers[header] !== 'undefined';
});
if (hasTransferEncodingHeader
|| (typeof outgoing.headers.connection === 'string'
&& hopByHopTransferEncodingHeader.test(outgoing.headers.connection))
) { outgoing.headers.connection = 'close'; }
Why was this needed? Even with deleteLength() fixed, if the forwarded connection stays alive via keep-alive, a different flavor of desync could still occur on that same connection. So the patch forces connection: close whenever the request carries a Transfer-Encoding header, or whenever the Connection header itself names transfer-encoding as a hop-by-hop token.
The old code only kept the connection alive as an exception when the Connection header contained an upgrade token (to support WebSockets) — and that exception appears to have been exploitable via something like Connection: Transfer-Encoding, upgrade. The patch reorders the checks so the Transfer-Encoding check runs first, forcing the connection closed regardless of any upgrade token.
The official patch ships with 5 regression tests covering these cases, each verifying that GET /secret never reaches the backend.
5. What the PoC Actually Shows
Public PoCs (learnerxuan/CVE-2026-29057-POC, Nayekah/Next.js-Proof-of-Concept) spin up the vulnerable version (15.5.12) and the patched version (15.5.13) side by side in Docker Compose, using identical topology, and fire the exact same raw chunked payload directly over a socket at both.
Vulnerable version output:
[after] {"backendRequests":["DELETE /rewrites/poc","GET /secret"],"flag":"FLAG{...}"}
PoC result: vulnerable behavior reproduced.
Patched version output:
[after] {"backendRequests":["DELETE /rewrites/poc"],"flag":null}
PoC result: smuggled request was not observed.
Same topology, same payload — only the Next.js version differs, yet the number of requests the backend receives changes. That's about as clear-cut a demonstration as you'll get.
6. Scope and Conditions
-
Not affected: Hosting environments where rewrites are handled at the CDN/edge layer (e.g., Vercel) are not affected. This issue targets self-hosted deployments where
rewrites()proxies from the Node.js runtime itself to an external backend. -
Attack requirements: No authentication or user interaction is needed — just network access (CVSS
PR:N,UI:N). That said, the attacker needs to know a configured rewrite path, and real impact depends on a downstream component (proxy, backend) that parses requests differently (AT:P, Attack Requirements: Present). -
Interim mitigation: If upgrading isn't immediately possible, block chunked-encoded
DELETE/OPTIONSrequests to rewrite-proxied paths at the edge/proxy layer, or enforce independent authentication/authorization on the backend routes themselves.





Top comments (0)