Skip to content

A returned Response's Content-Length is forwarded onto the re-encoded body, truncating the answer on the wire #3197

Description

@frenzzy

Summary

When a server function returns a Response carrying its own Content-Length, the transport merges that header onto its answer and then replaces the body with its own encoding. The declared length is the upstream's; the body is the runtime's. The message goes on the wire truncated.

Tested against next @ 1cc2feb8, built from source, Node 24.19, over a real node:http socket.

Reproduction

The shape return await fetch(upstream) produces — a Response with its own Content-Length:

registerServerFunction("proxy", async () =>
  new Response("upstream body", {
    headers: { "Content-Type": "text/html", "Content-Length": "13" }
  })
);

// control: identical, minus the Content-Length
registerServerFunction("proxy-nocl", async () =>
  new Response("upstream body", { headers: { "Content-Type": "text/html" } })
);

Served through node:http and fetched over a socket:

proxy       fn ran=1  declared content-length: "13"   bytes received: 13
proxy-nocl  fn ran=1  declared content-length: null   bytes received: 742

The encoded frame is 742 bytes. The client receives 13 of them and stops, because the declared length says the message ended. curl | wc -c agrees: 13.

What arrives is the first 13 bytes of the codec frame — ;0x00000262;{ — a frame header announcing 610 more payload bytes that never come.

It is road-specific, and that matters for the fix

The truncation happens only on the scripted /_server/data/ address, where the runtime re-encodes the body. On the plain /_server/ road the same function's Response passes through whole — declared 13 / sent 13 — because the author's length is the truth there and the runtime never touched the body.

That plain-road behaviour is a must-keep-working baseline, and a fix that strips Content-Length unconditionally at the transport edge would break it.

Four producers, not three

producer covered by fixing mergeResponseHeaders?
returned / thrown Response (scripted) yes
returned / thrown respond() envelope yes
middleware stub gap-fill no — needs Content-Length in STUB_GAP_FILL_EXCLUDED
respond() itself no — it stringifies the body and keeps the author's length, and the unscripted envelope path returns before the merge

The respond() row is the one most likely to be missed: UNSCRIPTED respond() envelope + CL=999 → declared 999, actual 18.

respond() is also the only place that can fix the null-body rows (204/205/304 carrying a length), since nothing else reaches them.

Scoping correction: the X-Content-Raw passthrough is a different defect. The runtime forwards the author's own body with the author's own wrong length — it never measured anything, so §8.6's "known to be incorrect" does not obviously apply. Listing it as the same defect overstates it, and the recommendation below deliberately leaves it alone.

Why this is the ordinary shape

return await fetch(...) is the natural way to write a proxying or forwarding server function, and every fetch response carries a Content-Length. The author writes nothing wrong; the header is not theirs.

Three independent entry paths reproduce it:

  • the enveloperespond(v, { headers: { "Content-Length": "999" } })content-length: 999 over a 17-byte JSON body
  • the raw passthrough — an X-Content-Raw response → content-length: 999 over 2 bytes
  • the stub gap-fill — middleware doing event.response.headers.set("Content-Length", "0")content-length: 0 over a 7-byte body (fillsStubGap blocks Content-Length only when response.body === null)

The rule

RFC 9110 §8.6:

a Content-Length field value that is inconsistent with the received message framing might cause a security failure due to request smuggling or response splitting. As a result, a sender MUST NOT forward a message with a Content-Length header field value that is known to be incorrect.

RFC 9112 §8:

A message that uses a valid Content-Length is incomplete if the size of the message body received is less than the value given by Content-Length.

Here the runtime knows the value is incorrect — it replaced the body itself.

The fix already exists, in one place

createNoJSHandler is the only site that reconciles this: it does headers.delete("Content-Length") after building its own answer. The envelope merge, the raw-Response merge and the stub gap-fill do not.

Worth noting Content-Type is handled asymmetrically on the same path: serializedResponse overwrites the author's with its own (text/plain), so that one cannot disagree — only the length is left stale.

Options

  1. Strip Content-Length in mergeResponseHeaders, the way createNoJSHandler already does. One line, covers all three entry paths, and the transport's own framing (chunked, or a length it computes) then describes the body it actually sent. This is what I'd suggest.
  2. Recompute it rather than dropping it — correct but needs the encoded length up front, which the streaming path does not have.
  3. Refuse a returned Response that carries a Content-Length with a legible error. Honest, but it breaks the proxy shape entirely, and the header is not the author's fault.
  4. Add Content-Length to STUB_GAP_FILL_EXCLUDED — closes the middleware road only, leaving the other two.

(1) + (4) + (5) is the set that closes every row: the merge, the stub gap-fill, and respond()'s own body plus the null-body statuses. Each alone leaves rows red — in particular (1) alone does not reach the unscripted envelope or the 204/205/304 cases.

Related, same rule, smaller: a Content-Length also survives onto 204/205/304 answers (Content-Length: "5" with body: null), which §8.6 forbids outright for 204 and permits on a 304 only when it equals the length a 200 would have sent. respond() already special-cases the null-body statuses; dropping the length there too would settle it.

Happy to send a PR. The regression test shape is the table above — the three entry paths, each asserting the bytes received equal the bytes encoded, with the no-Content-Length control that passes today.

Provenance

Not a regression — mergeResponseHeaders predates the recent work (it arrives with the TypeScript migration, 71821959). It has simply never reconciled a merged Content-Length against a body it replaces.

The precedent for the fix is already in the tree: createNoJSHandler does headers.delete("Content-Length") after building its own answer. Doing the same in the merge is a one-line change that needs no new concept — the transport already owns the framing of what it sends.

Implemented and verified — two lines

 function mergeResponseHeaders(target, source) {
   source.forEach((value, key) => {
-    if (key !== "set-cookie") target.append(key, value);
+    // `content-length` describes the source's body, and the caller is about
+    // to send a different one — forwarding a length known to be wrong
+    // truncates the answer at the socket (#3197, RFC 9110 §8.6). The
+    // transport frames what it actually sends.
+    if (key !== "set-cookie" && key !== "content-length") target.append(key, value);
   });

and, in STUB_GAP_FILL_EXCLUDED:

     REDIRECT_HEADER,
-    "Location"
+    "Location",
+    // the stub is written before the body exists, so its length can only
+    // ever describe a different one (#3197)
+    "Content-Length"
   ].map(header => header.toLowerCase())

9 insertions, 2 deletions across two files — seven of the insertions are comments, so two lines of executable change. Nothing is added: one name joins an existing exclusion list, and one condition gains a clause.

Verified on a real socket

proxy       fn ran=1  declared content-length: null  bytes received: 815   ← was 13 of 815
proxy-nocl  fn ran=1  declared content-length: null  bytes received: 742   ← control, unchanged

Full tracked suite with the change: 53 files, 586 passed, 2 skipped — no existing test moved.

What these two lines do not cover

respond() builds its own body with JSON.stringify and keeps the author's length, and the unscripted envelope path returns before the merge — so the envelope road and the 204/205/304 rows need a third change, headers.delete("Content-Length") inside respond(). Nothing else reaches those. Left out here so the two changes above can be judged on their own; happy to include it.

The X-Content-Raw passthrough is deliberately untouched: there the runtime forwards the author's own body, so the length is theirs to get right and §8.6's "known to be incorrect" does not apply.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions