Skip to content

node:http2: copy the array values of a raw header list in respond() and request() - #44804

Open
robobun wants to merge 4 commits into
mainfrom
robobun/12c95c7e/http2-raw-list-kept-array
Open

robobun wants to merge 4 commits into
mainfrom
robobun/12c95c7e/http2-raw-list-kept-array

Conversation

@robobun

@robobun robobun commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • stream.respond() and session.request() accept a flat [name, value, ...] list. respond([":status", 200, "set-cookie", KEPT, "set-cookie", sid]) pushes sid into KEPT.
  • A server with a constant KEPT sends each peer the cookies of earlier peers. Affected: 1.4.0 to main, not Node v26.3.0.
  • Cause: the fold of the list into sentHeaders stores 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

  • Both folds take a copy of an array value (copyHeaderValueArray).
  • Correct because the fold now pushes only into its own arrays.
  • Verified: test/js/node/http2/node-http2-header-list.test.ts (6 tests, 5 fail on main) and 10 upstream test-http2-* header files. Self-reviewed: 4 concerns raised, 4 addressed.

Background

  • sentHeaders is the object a stream shows for the headers it sent. For a flat list, repeated names collect in an array.
  • Node builds it in a getter and pushes into the caller's array (core.js#L2173). Node carries the value only after a read of sentHeaders. Deliberate difference: Bun pushes into a copy.
  • Found by comparison with Node. No user report.
  • Weighed: a lazy getter like Node's. It still needs the copy.

Downsides

Notes

Repro

import http2 from "node:http2";

const KEPT = ["theme=light; Path=/"];
const server = http2.createServer();
server.on("stream", (stream, headers) => {
  stream.respond([":status", 200, "set-cookie", KEPT, "set-cookie", "sid=" + headers["x-user"]]);
  stream.end("ok");
});
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
const client = http2.connect("http://127.0.0.1:" + server.address().port);
const ask = user =>
  new Promise(done => {
    const req = client.request({ ":path": "/", "x-user": user });
    let cookies;
    req.on("response", headers => (cookies = headers["set-cookie"]));
    req.resume();
    req.on("close", () => done(cookies));
    req.end();
  });
for (const user of ["alice", "bob", "carol"]) console.log(user, JSON.stringify(await ask(user)));
console.log("KEPT.length", KEPT.length);
client.destroy();
server.close();
bob receives KEPT.length
Node v26.3.0 ["theme=light; Path=/","sid=bob"] 1
Node v26.3.0, with void stream.sentHeaders; after respond() ["theme=light; Path=/","sid=alice","sid=bob"] 4
Bun main (bd599f5), release build ["theme=light; Path=/,sid=alice,sid=bob","sid=bob"] 4
this PR, with or without the read ["theme=light; Path=/","sid=bob"] 1
  • client.request([":path", "/", "x-tag", KEPT, "x-tag", token]) grows KEPT in the same way on main, and not on Node or with this PR.
  • A frozen KEPT makes request() and respond() throw TypeError: Attempted to assign to readonly property. on main. Node and this PR accept it.

The difference from Node

  • Node derives sentHeaders from 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.
  • Bun derives the object before the encode. With this PR it pushes into a copy, so a read of sentHeaders does not change the caller's array either.
  • Three of the six tests read sentHeaders in 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.
  • The shape of sentHeaders does not change: ["x", ["a"], "x", "b"] still shows x: ["a", "b"].

Measurements

  • Writes into a caller's array: 0 of 196 cases (main 140, Node v26.3.0 50, all 50 on a read of sentHeaders). The cases are respond() on a request stream, on a pushed stream and with a list that throws, and request() 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 whose slice() returns itself, a Proxy of an array, and an array from another realm.
  • JS cells per call against main (heapStats().objectTypeCounts, JIT off, GC off): object form 0, raw list without an array 0, one Array for each array value.
  • Bytecode (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.
  • Size: js/node/http2.js 168,930 B to 169,370 B (+440 B). No native change, no new host function.
  • Instructions per call are not measured: this machine has no PMU (perf_event_open returns ENOENT) and valgrind is not installed.

Not changed here

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.js pass.
  • 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.

…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.
@github-actions github-actions Bot added the claude label Oct 8, 2026
@robobun

robobun commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on a release build of main (bd599f5) with the script in the description. respond([":status", 200, "set-cookie", KEPT, "set-cookie", sid]) grows KEPT on every call (KEPT.length 4 after three responses), and bob receives sid=alice. Node v26.3.0 and this branch print KEPT.length 1.

This PR holds only the copy, split out of #37796. #37796 keeps the change to one field per element.

@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 8c05c328-6505-4642-b2d8-b2be844ee69c
📥 Commits

Reviewing files that changed from the base of the PR and between 8c0d2e6 and b393a3f.

📒 Files selected for processing (2)
  • src/js/node/http2.ts
  • test/js/node/http2/node-http2-header-list.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.


Walkthrough

HTTP/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.

Changes

HTTP/2 raw header array handling

Layer / File(s) Summary
Copy raw header arrays before folding
src/js/node/http2.ts, test/js/node/http2/node-http2-header-list.test.ts
A helper copies array-valued raw headers in respond() and request(). Tests check retained arrays and received headers for repeated values, pushed responses, connected and connecting requests, frozen arrays, and an array Proxy.

Suggested reviewers: cirospaciari

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to b393a

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)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: copying array values in raw HTTP/2 header lists used by respond() and request().
Description check ✅ Passed The description explains the problem, fix, scope, verification results, compatibility considerations, and known limitations. It does not use the exact template headings, but it provides the required c…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 with additionalPseudoHeaders.concat(raw), a user-overridable method, so a monkeypatched concat or a Symbol.isConcatSpreadable set 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.concat and calls ClientHttp2Session.request(rawList) with a list that omits a pseudo-header so additionalPseudoHeaders.length is nonzero. src/js/node/http2.ts:6050 additionalPseudoHeaders.concat(raw) resolves concat through Array.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.

Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts
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.
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
@robobun

robobun commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

On the review finding about additionalPseudoHeaders.concat(raw) in request(): that line is the same on main, and this PR does not touch it. concat is called on an array that request() made, so only a patched Array.prototype.concat, or Symbol.isConcatSpreadable on the caller's list, changes the result. I kept it out of this PR so that the diff stays on the write into the caller's array. #37796 changes that statement and is the place for it.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 headersObject write, so a throwing Proxy get trap leaves no partial state; copyHeaderValueArray uses $toLength/$newArrayWithSize/$putByValDirect as 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 error to reject, releases client/server in finally, 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.

robobun added a commit that referenced this pull request Oct 8, 2026
…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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant