node:http: apply joinDuplicateHeaders on HTTP/1 fallback connections - #38036
node:http: apply joinDuplicateHeaders on HTTP/1 fallback connections#38036robobun wants to merge 4 commits into
Conversation
Requests served by the JS HTTP/1 path (http2 allowHTTP1 connections that
negotiated http/1.1, and sockets handed to an http.Server through
server.emit("connection", socket)) built their IncomingMessage without
copying the server's joinDuplicateHeaders option onto it, so duplicates
of a single-valued header (Authorization, Content-Type, ...) were always
dropped there while the native http.Server path joined them.
Set the flag on the request before its header lines are added, as Node's
parserOnHeadersComplete does, and make Http2SecureServer with allowHTTP1
store (and type-check) the option from options / options.http1Options
next to the other HTTP/1 options it already keeps, as Node's
storeHTTPOptions does.
|
Warning Review limit reached
Next review available in: 15 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (5)
Comment |
|
Updated 5:05 AM PT - Aug 13th, 2026
❌ @robobun, your commit 0c21d69 has some failures in 🧪 To try this PR locally: bunx bun-pr 38036That installs a local version of the PR into your bun-38036 --bun |
|
Status: fix is up in this PR (current head 0c21d69). Reproduced with the scripts in the PR description ( |
There was a problem hiding this comment.
LGTM — small, focused Node compat fix that mirrors the existing native-path pattern.
What was reviewed:
- Verified the fallback's
if (server.joinDuplicateHeaders) { req.joinDuplicateHeaders = true; }is byte-identical to the native dispatcher in_http_server.ts:791-793, and that_addHeaderLinein_http_incoming.tsreads it asthis.joinDuplicateHeadersbefore the flag is consulted. - Verified the http2.ts hunk sits inside
{ ...options, ...options.http1Options }sohttp1Optionswins,validateBooleanis already imported, and the validate-then-unconditionally-assign shape matchesstoreHTTPOptionsin_http_server.ts:3956-3960. - Checked the
Object.hasOwn(..., "joinDuplicateHeaders")assertion holds because the whole block is gated onallowHTTP1 === true, and the{ server: undefined }case is consistent with the unconditional assignment. - Tests wire error/close to reject, clean up in
finally, and cover the true/false/unset × top-level/http1Options/precedence matrix plus the type-check error message.
Extended reasoning...
Overview
Two small src changes (~8 lines total) plus three new tests. http1_server_fallback.ts copies server.joinDuplicateHeaders onto the IncomingMessage before _addHeaderLines, exactly as the native dispatcher already does. http2.ts reads, type-checks, and stores joinDuplicateHeaders in the existing allowHTTP1 block alongside maxHeaderSize/insecureHTTPParser/etc., using the same shape as storeHTTPOptions in _http_server.ts.
Security risks
None. This only affects how duplicate request header values are combined into req.headers (join with ", " vs. keep-first) when the user explicitly opts in — no new parsing, no auth/permission surface, and the behavior already exists on the native path.
Level of scrutiny
Low. Both changes are copy-the-existing-pattern edits: the fallback line matches _http_server.ts:791-793 verbatim, and the http2 block matches storeHTTPOptions at _http_server.ts:3956-3960. validateBoolean is already imported in http2.ts. The http1Options local is { ...options, ...options.http1Options }, so precedence matches Node and the tests. The header-combining logic in _http_incoming.ts (_addHeaderLine reading this.joinDuplicateHeaders) is unchanged and already correct for the native path.
Other factors
Tests are well-formed per the repo's review guidance: failure events (error, close) are wired to reject the awaited promise, cleanup happens in finally, assertions are exact (toEqual on full objects, expect.objectContaining with code and full message for the throw), and the variant matrix is covered (true/false/unset for emit; top-level/http1Options/both-with-precedence/false/unset for allowHTTP1; type error for both spellings; no validation without allowHTTP1). The PR description confirms fail-before/pass-after and cross-checks against Node v26.3.0. The noted overlap with #37735/#37739 is a mechanical merge conflict, not a correctness concern.
… tidy the http2 option block The node-http test now runs the same raw request through a listening server built from the same options and expects both paths to produce the same req.headers, so it pins the fallback to the native path as well as to node's values. In http2.ts the new option sits with the per-request options rather than under the comment describing the parser-init ones, and that comment names node's storeHTTPOptions (storeHTTP1Options does not exist).
There was a problem hiding this comment.
LGTM — small, well-scoped Node compat fix that mirrors the existing native-path pattern exactly.
What was reviewed:
http1_server_fallback.tschange is byte-for-byte the same shape as the native dispatcher at_http_server.ts:791-793; placed before_addHeaderLinesso_addHeaderLinesees the flag.http2.tschange matchesstoreHTTPOptionsin_http_server.ts:3956-3960and_http_client.ts:291-296(validate-if-defined, then store);validateBooleanalready imported.- Tests cover both entry points (emit-connection and allowHTTP1), true/false/unset,
http1Optionsprecedence, and theERR_INVALID_ARG_TYPEgate; error/close events are wired to reject and resources released infinally.
The comment-cop bot flag on the two-line comment at http1_server_fallback.ts:294-295 looks like a false positive — it's shorter than most neighbouring comments in that file and isn't justifying a workaround.
Extended reasoning...
Overview
Two source changes plus tests:
src/js/internal/http1_server_fallback.ts: copyserver.joinDuplicateHeadersonto the freshly-constructedIncomingMessagebefore_addHeaderLines, so_addHeaderLinein_http_incoming.tstakes its join branch for single-valued headers. This is the same conditional the native dispatcher already applies at_http_server.ts:791-793.src/js/node/http2.ts: in theallowHTTP1block ofHttp2SecureServer, readjoinDuplicateHeadersfrom the merged{ ...options, ...options.http1Options }bag,validateBooleanwhen defined, and store onthis— the same shape asstoreHTTPOptionsin_http_server.ts:3956-3960and the client at_http_client.ts:291-296. Also fixes a stalestoreHTTP1Options→storeHTTPOptionsreference in the adjacent comment.- New tests in
node-http.test.ts(emit-connection overduplexPaircompared against the same server listening natively, for true/false/unset) andnode-http2.test.js(allowHTTP1 over TLS negotiating http/1.1, top-level vshttp1Optionsvs both, plus type-check behaviour with and withoutallowHTTP1).
Security risks
None. joinDuplicateHeaders is an opt-in Node-compat option that changes how duplicate single-valued request headers are folded into req.headers (join with ", " instead of first-wins). It doesn't relax parsing, doesn't touch auth/TLS, and only fires when the application explicitly sets the option. The type check added in http2.ts is a strictness increase, matching Node.
Level of scrutiny
Low-to-medium. The runtime change is ~5 lines that copy an established pattern verbatim from two sibling call sites; the header-combining logic in _addHeaderLine is unchanged. The PR description includes a Node v26.3.0 comparison table for every option shape, and the tests assert against the listening (native) path so a divergence would surface. Node/Web compat area, but this is plumbing an existing flag through a path that forgot to read it, not new behaviour.
Other factors
- All required imports (
validateBoolean,duplexPair,once,connect,AddressInfo) are already present in the touched files. - Tests follow harness conventions:
port: 0, error/close wired to reject the awaited promise, cleanup infinally, exact-value assertions on the whole headers object, and the http test compares the fallback path against the native path so it can't vacuously pass. - The
github-actionscomment-cop flag on the new two-line comment appears to be a false positive: it's a reference to Node'sparserOnHeadersComplete(the pattern REVIEW.md asks for on ported code), not a workaround justification, and is shorter than the surrounding comments in the same function. - The PR notes small mechanical conflicts with #37735/#37739; whichever lands second resolves trivially and doesn't affect this change's correctness.
|
Self-review follow-ups, pushed as aafb657 and d6960b1 (no change to the fix itself):
All three new tests still fail on bun 1.4.0 and pass on the debug build, as do the upstream |
There was a problem hiding this comment.
LGTM — small Node.js compat fix that mirrors the native dispatcher and storeHTTPOptions exactly.
What was reviewed:
http1_server_fallback.ts: the addedreq.joinDuplicateHeadersassignment matches the native path at_http_server.ts:791-793verbatim and runs before_addHeaderLines.http2.ts:validateBooleanis already imported; the mergedhttp1Optionsbag at line 6801 giveshttp1Optionsprecedence as Node does; the validate-then-store shape matchesstoreHTTPOptionsin_http_server.ts:3956-3960.- Tests cover both entry paths (emit-connection and allowHTTP1), true/false/unset,
http1Optionsprecedence, and the type-check gated onallowHTTP1. - The comment-cop bot note appears stale/misfired — the added comment is already one line as of d6960b1.
Extended reasoning...
Overview
This PR wires the joinDuplicateHeaders server option through to requests served by the JS HTTP/1 fallback (server.emit('connection', ...) and http2.createSecureServer({ allowHTTP1: true })). Two source changes: a 4-line addition in src/js/internal/http1_server_fallback.ts that copies server.joinDuplicateHeaders onto the IncomingMessage before _addHeaderLines, and a 3-line addition in src/js/node/http2.ts that reads, type-checks, and stores the option in the existing allowHTTP1 block. Three new tests across node-http.test.ts and node-http2.test.js.
Security risks
None. The header-combining logic in _addHeaderLine is unchanged; this only propagates an existing boolean flag so the fallback path behaves the same as the native path and Node. No new parsing, no new trust boundaries.
Level of scrutiny
Low-to-medium. The source changes are tiny and each is a byte-for-byte copy of an existing pattern: the fallback assignment mirrors _http_server.ts:791-793, and the http2 storage mirrors storeHTTPOptions at _http_server.ts:3956-3960 (same validateBoolean guard, same undefined-passes-through, same unconditional store). The http1Options precedence comes from the pre-existing { ...options, ...options.http1Options } merge two lines above. Also confirmed validateBoolean is already imported in http2.ts.
Other factors
- Tests are thorough: they compare the emitted-connection path against the listening (native) path with the same request bytes, cover true/false/unset, single-valued vs always-joined headers,
http1Optionsprecedence over top-level, and that validation only fires withallowHTTP1. Failure paths are wired to reject the awaited promise. - The PR description tabulates node v26.3.0 vs bun 1.4.0 vs this PR for every option combination, and lists the upstream Node tests that pass.
- The github-actions comment-cop note on
http1_server_fallback.tswas posted after d6960b1 already shortened the comment to one line; the added comment in the diff is a single line, so this looks like a bot misfire rather than an outstanding request. - The description flags known small mechanical conflicts with #37735 / #37739 — that's a merge-order concern, not a correctness one.
node-http.test.ts runs node-http-proxy.js, whose proxy server listened on "localhost" while the client dialed "localhost" too; on hosts where the server side of that resolves to ::1 and the client side to 127.0.0.1 the request fails with ECONNREFUSED (node behaves the same way with this setup). Bind 127.0.0.1 and dial the bound address.
There was a problem hiding this comment.
LGTM — small Node-compat fix that mirrors the native path exactly.
What was reviewed:
- Confirmed the fallback's
if (server.joinDuplicateHeaders)block matches the native dispatcher in_http_server.tsline-for-line, and_addHeaderLinereads it asthis.joinDuplicateHeaders. - Confirmed the
http2.tsaddition matchesstoreHTTPOptions(validate-if-not-undefined, then store),validateBooleanis already imported, and thehttp1Optionslocal is{ ...options, ...options.http1Options }so precedence is Node's. - Tests cover true/false/unset, top-level vs
http1Optionsvs both, the type error, and parity between the emitted-connection and listening paths; error events are wired to reject and cleanup is infinally. - The
node-http-proxy.jschange just binds and dials127.0.0.1— a hermetic-test fix, no behavior change.
Extended reasoning...
Overview
Two source changes totalling ~7 lines: src/js/internal/http1_server_fallback.ts copies server.joinDuplicateHeaders onto the IncomingMessage before _addHeaderLines (so _addHeaderLine's join branch fires on the JS fallback path the way it already does on the native path), and src/js/node/http2.ts reads/validates/stores joinDuplicateHeaders in the allowHTTP1 block so the fallback has something to read. Three new tests plus a drive-by hermetic fix to node-http-proxy.js (bind/dial 127.0.0.1 instead of localhost to avoid IPv6/IPv4 mismatch).
Security risks
None. The header-combining logic in _addHeaderLine is unchanged; this only propagates an existing, already-validated server option to a code path that was missing it. The new validateBoolean call adds strictness rather than removing any.
Level of scrutiny
Low-to-medium. Pure JS Node-compat layer, no native/memory concerns. Both source edits are line-for-line copies of established patterns: the native dispatcher at _http_server.ts:791-793 and storeHTTPOptions at _http_server.ts:3956-3960. I verified validateBoolean is imported, the http1Options local already spreads { ...options, ...options.http1Options } so http1Options wins as in Node, and IncomingMessage defaults joinDuplicateHeaders to false so the truthy-only assignment matches the native path's semantics.
Other factors
Test coverage is thorough for the size of the fix — the parity test compares the emitted-connection path against a listening server built from the same options across true/false/unset, and the http2 tests cover both option spellings, precedence, the stored value, and the ERR_INVALID_ARG_TYPE (including that it does not throw without allowHTTP1). Error events are wired to reject and resources are released in finally. The comment-cop bot's note about a long comment was addressed (now one line, thread resolved). The PR description is upfront about the two known adjacent divergences it deliberately leaves to #37735, which is the right scoping call.
|
Ready for review at 0c21d69. Verification summary for the current head:
|
Problem
src/js/internal/http1_server_fallback.tsignores the server'sjoinDuplicateHeadersoption: withhttp.createServer({ joinDuplicateHeaders: true })and a request carryingAuthorization: oneandAuthorization: two, a handler reached throughserver.emit("connection", socket)seesreq.headers.authorization === "one". The same server's listening (native) path, and node for both paths, give"one, two".http2.createSecureServer({ allowHTTP1: true, joinDuplicateHeaders: true })(orhttp1Options: { joinDuplicateHeaders: true }) has the same problem for connections that negotiatedhttp/1.1, and non-boolean values are accepted where node throwsERR_INVALID_ARG_TYPE.connectionListenerHTTP1'skOnHeadersCompletebuilds theIncomingMessageitself and never setsreq.joinDuplicateHeaders, so_addHeaderLine(src/js/node/_http_incoming.ts) takes its drop-duplicates branch for every single-valued header. Node'sparserOnHeadersCompletecopies the flag from the server; so does bun's native dispatcher (_http_server.ts,server.joinDuplicateHeaders->http_req.joinDuplicateHeaders), which is why only the fallback differs.Http2SecureServercopies the other HTTP/1 options (keepAliveTimeout,maxHeaderSize, ...) onto itself in itsallowHTTP1block insrc/js/node/http2.tsbut not this one, so even with the first part fixed there would be nothing for the fallback to read.http.Serveralready stores it (storeHTTPOptions), so theemit("connection")path only needs the first part.Fix
http1_server_fallback.ts: setreq.joinDuplicateHeaders = truewhen the server has the option, before_addHeaderLines, in the same form as the native dispatcher.http2.ts: in theallowHTTP1block, readjoinDuplicateHeadersfrom{ ...options, ...options.http1Options }like the options next to it, type-check it, and store it on the server (next tomaxRequestsPerSocket, the other option the fallback reads per request; the comment on the three parser-init options below it now names node'sstoreHTTPOptions, the function it was already describing).storeHTTPOptions.call(this, { ...options, ...options.http1Options })inHttp2SecureServerwhenallowHTTP1is set (sohttp1Optionswins, non-booleans throwERR_INVALID_ARG_TYPE, and nothing is validated or stored withoutallowHTTP1), and itsparserOnHeadersCompletecopiessocket.server.joinDuplicateHeadersonto every request. The header combining itself is unchanged:_addHeaderLinealready implements the joined form for the native path, it just never saw the flag on fallback requests. Both src changes were checked against node v26.3.0 on the scripts in the details below.test/js/node/http/node-http.test.ts: the same raw request (doubledAuthorization,Content-Type,CookieandX-Custom) throughserver.emit("connection")over aduplexPairand through a listening server built from the same options, forjoinDuplicateHeaderstrue / false / unset; both paths must produce the same completereq.headers, which is node's (single-valued headers joined only with the option,Cookie/ unknown headers joined regardless).test/js/node/http2/node-http2.test.js:allowHTTP1over a TLS connection negotiatinghttp/1.1for the option given at top level, inhttp1Options, in both (http1Options wins), false, and unset, including the value stored on the server; and theERR_INVALID_ARG_TYPEfor both spellings, with nothing stored or validated withoutallowHTTP1.test/js/node/http/node-http-proxy.js(run bynode-http.test.ts, unrelated to the fix): the proxy server now binds 127.0.0.1 and the client dials the bound address. Listening on and dialing"localhost"fails withECONNREFUSEDon hosts where the server side resolves it to::1and the client side to 127.0.0.1 (node does the same with that setup), which made the full file fail in such environments. Same hunk as in node:http: enforce headersTimeout and requestTimeout on HTTP/1 fallback connections #37739."one"where"one, two"is expected; no throw); with them, all three pass, as do the fullnode-http.test.tsandnode-http2.test.jsfiles and the upstreamtest-http-request-join-authorization-headers,test-http-generic-streams,test-http2-allow-http1,test-http2-https-fallback*,test-http2-createsecureserver-optionsandtest-http-server-options-*tests.requireHostHeader; it addsrequireHostHeaderat the same spot inhttp2.tsin the same style) and node:http: enforce headersTimeout and requestTimeout on HTTP/1 fallback connections #37739 (switches that block tostoreHTTPOptions, which would store this option too, but nothing on the fallback side reads it). Whichever lands second has a small, mechanical conflict.maxHeadersCount; both are what node:http: deliver all request headers on HTTP/1 fallback connections and enforce requireHostHeader #37735 fixes, so the new parity test uses a small request and leaves those cases to that PR's tests.Background
joinDuplicateHeaders(node 18.14+): a server (and client) option.IncomingMessage.headersis built by_addHeaderLine, which has fixed rules per header name: most are joined with", ",Cookiewith"; ",Set-Cookiebecomes an array, and a list of single-valued headers (Authorization,Content-Type,Host,User-Agent, ...) keeps the first value. The option makes that last group join with", "as well (RFC 9110 section 5.3);_addHeaderLinereads it asthis.joinDuplicateHeaderson the request, so whoever constructs the request has to copy it from the server.http.Servernormally serves connections natively. Two cases are served by JS instead, driving the llhttp-basedHTTPParserbinding over a plainDuplex: anhttp2.createSecureServer({ allowHTTP1: true })connection whose ALPN negotiation pickedhttp/1.1(or nothing), and any socket an application hands to anhttp.Serverwithserver.emit("connection", socket). That code builds its ownIncomingMessage/ServerResponse, so server options only apply there if it copies them over itself.http1Options: node letscreateSecureServertake the HTTP/1 server options either at top level or grouped underoptions.http1Options, the latter taking precedence.Repro scripts and output (node v26.3.0 vs bun 1.4.0 vs this PR)
server.emit("connection"):allowHTTP1(TLS client withALPNProtocols: ["http/1.1"], same twoAuthorizationlines), per server options,req.headers.authorization/server.joinDuplicateHeaders:{ joinDuplicateHeaders: true }one, two/trueone/undefinedone, two/true{ http1Options: { joinDuplicateHeaders: true } }one, two/trueone/undefinedone, two/true{ joinDuplicateHeaders: true, http1Options: { joinDuplicateHeaders: false } }one/falseone/undefinedone/false{ joinDuplicateHeaders: false }one/falseone/undefinedone/false{}one/undefinedone/undefinedone/undefined{ joinDuplicateHeaders: "yes" }ERR_INVALID_ARG_TYPEERR_INVALID_ARG_TYPE{ http1Options: { joinDuplicateHeaders: 1 } }ERR_INVALID_ARG_TYPEERR_INVALID_ARG_TYPE{ joinDuplicateHeaders: "yes" }withoutallowHTTP1The error message is node's verbatim:
The "options.joinDuplicateHeaders" property must be of type boolean. Received type string ('yes').