DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

CVE-2026-29057 — HTTP Request Smuggling via Next.js Rewrites

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."

  1. The attacker sends a DELETE /rewrites/poc request with Transfer-Encoding: chunked, hiding an entire second request — GET /secret HTTP/1.1... — as plain text inside the chunked body.
  2. Next.js correctly understands Transfer-Encoding and decodes the body, then passes the request through an internal function called deleteLength() before forwarding it to the backend.
  3. Due to a bug, that function attaches Content-Length: 0 and deletes the Transfer-Encoding header — but the body bytes are still streamed through via pipe().
  4. An intermediate proxy or load balancer sees Content-Length: 0 and assumes the request ends there, parsing the remaining bytes as a brand-new, independent request.
  5. The backend ends up receiving two requests: DELETE /rewrites/poc and GET /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'];
  }
},
Enter fullscreen mode Exit fullscreen mode

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';
  }
},
Enter fullscreen mode Exit fullscreen mode

In plain terms:

  • This function exists because DELETE and OPTIONS requests typically have no body, so it tries to explicitly mark that with Content-Length: 0.
  • Before the patch: the only condition checked is !req.headers['content-length'] — "if there's no Content-Length header." But a Transfer-Encoding: chunked request is supposed to have no Content-Length header; that's normal. So this condition evaluates to true for chunked requests too, forcibly setting Content-Length: 0 and then deleting the Transfer-Encoding header. 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'). If Transfer-Encoding: chunked is 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'; }
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Patched version output:

[after] {"backendRequests":["DELETE /rewrites/poc"],"flag":null}
PoC result: smuggled request was not observed.
Enter fullscreen mode Exit fullscreen mode

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/OPTIONS requests to rewrite-proxied paths at the edge/proxy layer, or enforce independent authentication/authorization on the backend routes themselves.

Top comments (0)