Repository navigation
Conversation
…nd request() respond() and request() fold a raw [name, value, ...] list into the object behind sentHeaders. The fold pushes a later duplicate of a name into the name's array. When that array came from the caller, the push changed the caller's array, and the list was encoded after the push. A caller that keeps one array and passes it in every list then sent each peer the values of the peers before it. The fold now takes a copy of an array value.
|
Status: reproduced on a release build of main (bd599f5) with the script in the description. This PR holds only the copy, split out of #37796. #37796 keeps the change to one field per element. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (2)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review. WalkthroughHTTP/2 server responses and client requests now copy array-valued entries from raw header lists before folding them into derived headers. Regression tests cover retained and frozen arrays, pushed responses, and requests made before and after a client session connects. ChangesHTTP/2 raw header array handling
Suggested reviewers: Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to This change prevents duplicate-header folding from altering caller-owned arrays and affecting later header lists. No concrete remaining merge risk is established; the change is ready for normal checks. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked the compat-API sibling site: Http2ServerResponse.appendHeader (src/js/node/http2.ts:978-989) pushes into the array the caller passed to setHeader, but that matches Node's own appendHeader, so it is not a divergence introduced or missed here.
Extended reasoning...
The change copies array-valued slots when folding a raw header list in ServerHttp2Stream.respond() and ClientHttp2Session.request() in src/js/node/http2.ts, plus a new loopback test file; no auth, crypto, or parsing surface is touched. Three inline findings were posted (Proxy-over-array length handling in the new copy helper, sentHeaders identity change versus Node, and the user-overridable concat on the request() wire path), so a human look is already signalled; this note only records the compat-API appendHeader path that was examined and found consistent with Node.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟣
src/js/node/http2.ts— Clients whose app or a dependency patches Array.prototype.concat get wrong or thrown request headers on every raw-list request() call. src/js/node/http2.ts:6050 builds the wire list withadditionalPseudoHeaders.concat(raw), a user-overridable method, so a monkeypatched concat or aSymbol.isConcatSpreadableset on the caller's list changes what the fold at :6052-6060 and the encoder at :6312 see. Fix: build rawHeadersList with intrinsics ($newArrayWithSize+$putByValDirect, or a loop) as copyHeaderValueArray already does, so the request() raw path never routes through Array.prototype.Why this was flagged
Trigger: any process where Array.prototype.concat has been replaced (polyfills, instrumentation, prototype pollution via a vulnerable dependency) or where the caller's raw list has Symbol.isConcatSpreadable / a nested array value with isConcatSpreadable set; the entry point is ClientHttp2Session.request(rawList) with a session that needs pseudo-headers added, which is every raw-list request that omits :authority, :scheme or :path. src/js/node/http2.ts:6050 calls
additionalPseudoHeaders.concat(raw)through the prototype, unlike the new copy helper at :3922-3927 which uses $newArrayWithSize and$putByValDirect. The result is used both for sentHeaders at :6052-6060 and for the wire at :6285/:6310, so a patched concat alters the HEADERS frame or throws out of request(). The base branch has the same line, but the PR's stated goal is a tamper-proof fold and src/js/CLAUDE.md requires $ -prefixed intrinsics in this file; four finders independently flagged it. Remedy: concatenate with a counted loop using $putByValDirect.Verification: pre-existing. The process has replaced
Array.prototype.concatand callsClientHttp2Session.request(rawList)with a list that omits a pseudo-header soadditionalPseudoHeaders.lengthis nonzero. src/js/node/http2.ts:6050additionalPseudoHeaders.concat(raw)resolvesconcatthroughArray.prototype, not an intrinsic; its result goes on the wire. The base has the identical call at line 6038, so it fails by exactly the same route.
A Proxy of an array can report a length that is not an array length. The copy asked for a new array of that size, so respond() and request() threw a RangeError for a value that was sent before. ToLength reads the length as Array.prototype.join does when the encoder makes the field value.
|
On the review finding about |
There was a problem hiding this comment.
LGTM — the follow-up commits address both points from the earlier review, and the fix is a small, self-contained defensive copy.
What was reviewed:
- Both fold sites (respond() ~L3603, request() ~L6053) copy before any native encode or
headersObjectwrite, so a throwing Proxygettrap leaves no partial state;copyHeaderValueArrayuses$toLength/$newArrayWithSize/$putByValDirectas other builtins do. - Checked the other header-taking entry points (pushStream, additionalHeaders, sendTrailers, Http2ServerResponse paths): they reject the raw-array form, so respond()/request() are the only consumers of a raw list and the class is covered.
- The new test wires
errorto reject, releases client/server infinally, and covers the frozen-array and Proxy-length cases that the earlier Proxy concern raised.
Extended reasoning...
The diff adds a 7-line helper in src/js/node/http2.ts and one guard line at each of the two raw-header-list fold sites (ServerHttp2Stream.respond and ClientHttp2Session.request), plus a new 178-line test file. It touches header handling in node:http2, where the underlying bug could leak one peer's set-cookie values to another, but the fix is a plain copy of a caller-owned array performed before any encoding, with no native or security-gate changes. Both prior inline comments from this bot (Proxy length, sentHeaders identity divergence) were addressed by later commits and the divergence is commented with a Node permalink; the github-actions threads were followed by commits. Neither file is covered by CODEOWNERS, the hunt ran dry with no findings, and the change is small enough to verify by reading.
…d9e6e94/http2-raw-array-headers This brings in main and the copy of the array values (#44804). The merge takes that tree as it is: the native array arm of this branch and its tests in node-http2.test.js are gone. The next commit adds the change to one field per element in JS, on top of the copy.
Problem
stream.respond()andsession.request()accept a flat[name, value, ...]list.respond([":status", 200, "set-cookie", KEPT, "set-cookie", sid])pushessidintoKEPT.KEPTsends each peer the cookies of earlier peers. Affected: 1.4.0 to main, not Node v26.3.0.sentHeadersstores the caller's array (src/js/node/http2.ts:3605,:6045). It pushes a later duplicate of the name into that array, and the list is encoded afterwards.Fix
copyHeaderValueArray).test/js/node/http2/node-http2-header-list.test.ts(6 tests, 5 fail on main) and 10 upstreamtest-http2-*header files. Self-reviewed: 4 concerns raised, 4 addressed.Background
sentHeadersis the object a stream shows for the headers it sent. For a flat list, repeated names collect in an array.sentHeaders. Deliberate difference: Bun pushes into a copy.Downsides
Http2ServerResponsepushes as in Node. node:httpwriteHead(list)is node:http: copy the array values of a header list in writeHead() and res.headers= #44778.sentHeadersis now a copy. Code that compares it by identity with its own array sees a difference from Node.Notes
Repro
bobreceivesKEPT.length["theme=light; Path=/","sid=bob"]void stream.sentHeaders;afterrespond()["theme=light; Path=/","sid=alice","sid=bob"]["theme=light; Path=/,sid=alice,sid=bob","sid=bob"]["theme=light; Path=/","sid=bob"]client.request([":path", "/", "x-tag", KEPT, "x-tag", token])growsKEPTin the same way on main, and not on Node or with this PR.KEPTmakesrequest()andrespond()throwTypeError: Attempted to assign to readonly property.on main. Node and this PR accept it.The difference from Node
sentHeadersfrom the caller's list in a getter. The first read pushes a later duplicate into the caller's array (core.js#L2157-L2186). Node encodes the header block before that, inside the call.sentHeadersdoes not change the caller's array either.sentHeadersin their last three calls and then expect the kept array to be unchanged. Under Node v26.3.0 those three tests pass for the calls before the read and fail after it. The pushed-stream test and the frozen-array test pass under Node.sentHeaders[name]is a copy of an array value, not the array that the caller passed. Node and Bun 1.4.x store the caller's array there for a name that occurs once.sentHeadersdoes not change:["x", ["a"], "x", "b"]still showsx: ["a", "b"].Measurements
sentHeaders). The cases arerespond()on a request stream, on a pushed stream and with a list that throws, andrequest()on a connected session, on a connecting session, with a list that throws and with an aborted signal. Each case has frozen arrays in the value slots. The count is also 0 for an Array subclass, a subclass whoseslice()returns itself, a Proxy of an array, and an array from another realm.heapStats().objectTypeCounts, JIT off, GC off): object form 0, raw list without an array 0, one Array for each array value.BUN_JSC_dumpGeneratedBytecodes=1): a raw list runs 2 more ops per pair (typeof_is_object,jfalse). The object form does not reach the changed loop.js/node/http2.js168,930 B to 169,370 B (+440 B). No native change, no new host function.perf_event_openreturnsENOENT) andvalgrindis not installed.Not changed here
,. node:http2: send array values in raw-form header lists as one field per element #37796 changes that.Http2ServerResponse:res.setHeader(name, ARR)and thenres.appendHeader(name, value)pushes intoARR. Node v26.3.0 does the same (measured with the same kind of script).res.writeHead(status, list)has the same write into the caller's array. node:http: copy the array values of a header list in writeHead() and res.headers= #44778 fixes it.request()is encoded when the session connects, from the caller's list. A change that the caller makes after the call is sent (related: node:http2: coerce outbound header values before taking a stream id #37568).Suites run with the debug build
test/js/node/http2/node-http2-header-list.test.ts: 6 pass. 5 of them fail with a release build of main. The sixth is a guard for the copy: a Proxy of an array that reports a length of 1.5 is sent as on main. Node v26.3.0 aborts on that value (Assertion failed: header_count->IsUint32()).test/js/node/test/parallel/:test-http2-raw-headers.js,test-http2-raw-headers-defaults.js,test-http2-sensitive-headers.js,test-http2-sent-headers.js,test-http2-multiheaders.js,test-http2-multiheaders-raw.js,test-http2-cookies.js,test-http2-single-headers-validation.js,test-http2-single-headers-validation-disabled.js,test-http2-zero-length-header.jspass.test/js/node/http2/node-http2.test.js: no test that uses a raw header list fails. The tests that fail on this machine are timeouts in subprocess tests, and they fail with the source of main too.