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 envelope —
respond(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
- 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.
- Recompute it rather than dropping it — correct but needs the encoded length up front, which the streaming path does not have.
- 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.
- 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.
Summary
When a server function returns a
Responsecarrying its ownContent-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 realnode:httpsocket.Reproduction
The shape
return await fetch(upstream)produces — aResponsewith its ownContent-Length:Served through
node:httpand fetched over a socket:The encoded frame is 742 bytes. The client receives 13 of them and stops, because the declared length says the message ended.
curl | wc -cagrees: 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'sResponsepasses 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-Lengthunconditionally at the transport edge would break it.Four producers, not three
mergeResponseHeaders?Response(scripted)respond()envelopeContent-LengthinSTUB_GAP_FILL_EXCLUDEDrespond()itselfThe
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/304carrying a length), since nothing else reaches them.Scoping correction: the
X-Content-Rawpassthrough 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 everyfetchresponse carries aContent-Length. The author writes nothing wrong; the header is not theirs.Three independent entry paths reproduce it:
respond(v, { headers: { "Content-Length": "999" } })→content-length: 999over a 17-byte JSON bodyX-Content-Rawresponse →content-length: 999over 2 bytesevent.response.headers.set("Content-Length", "0")→content-length: 0over a 7-byte body (fillsStubGapblocksContent-Lengthonly whenresponse.body === null)The rule
RFC 9110 §8.6:
RFC 9112 §8:
Here the runtime knows the value is incorrect — it replaced the body itself.
The fix already exists, in one place
createNoJSHandleris the only site that reconciles this: it doesheaders.delete("Content-Length")after building its own answer. The envelope merge, the raw-Responsemerge and the stub gap-fill do not.Worth noting
Content-Typeis handled asymmetrically on the same path:serializedResponseoverwrites the author's with its own (text/plain), so that one cannot disagree — only the length is left stale.Options
Content-LengthinmergeResponseHeaders, the waycreateNoJSHandleralready 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.Responsethat carries aContent-Lengthwith a legible error. Honest, but it breaks the proxy shape entirely, and the header is not the author's fault.Content-LengthtoSTUB_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-Lengthalso survives onto 204/205/304 answers (Content-Length: "5"withbody: 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-Lengthcontrol that passes today.Provenance
Not a regression —
mergeResponseHeaderspredates the recent work (it arrives with the TypeScript migration,71821959). It has simply never reconciled a mergedContent-Lengthagainst a body it replaces.The precedent for the fix is already in the tree:
createNoJSHandlerdoesheaders.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: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
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 withJSON.stringifyand keeps the author's length, and the unscripted envelope path returns before the merge — so the envelope road and the204/205/304rows need a third change,headers.delete("Content-Length")insiderespond(). 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-Rawpassthrough 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.