Repository navigation
Conversation
…er element The raw [name, value, ...] form accepted by session.request() and stream.respond() stringified an array value slot, so ["ra", "rb"] went out as a single field "ra,rb". The object form already encodes one field per element; node's buildNgHeaderString does the same for both forms. The native raw-pairs walk in H2FrameParser::request() gets the same array branch as the object walk: one field per element, each element validated like a single value, an empty array sends nothing, and the single-value rule applies to the array as a whole. The sentHeaders object derived from a raw list no longer accumulates a later duplicate into the caller's own array: that array is also what the encoder reads, so ["x", ["a"], "x", "b"] would have sent b twice.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
WalkthroughHTTP/2 request and response header handling now preserves raw arrays, validates each header occurrence, and encodes array elements as separate fields. Tests cover duplicates, sensitive headers, empty arrays, single-value restrictions, relaxed validation, and invalid values. ChangesHTTP/2 header encoding
Possibly related PRs
Suggested reviewers: Pre-merge checks:
|
|
Updated 10:05 PM PT - Oct 17th, 2026
❌ @robobun, your commit 0d9bc01 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 37796That installs a local version of the PR into your bun-37796 --bun |
|
Status: reproduced on a release build of main (bd599f5) and on Node v26.3.0 with the script in the description. This PR is stacked on #44804. Merge #44804 first. The diff is ready for a maintainer: the http2 suites pass, and the red lanes on the last two CI runs are retried flakes in unrelated suites (install, decorators, napi, fetch-leak, filesink, socket, cluster) and one darwin aarch64 runner failure. |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes the native HTTP/2 header-encoding path and carries a deliberate Node divergence plus noted interactions with #33470 and #37568, a human look is still worthwhile.
What was reviewed:
- The new
encode_valueclosure and array branch in the raw-pairs walk against the existing object-form array handling (single-value rule, never-index, empty-array skip, error paths). - Borrow/provenance of the closure's captures — all methods it calls take
&self, so no aliasing conflict with thesingle_value_headersbookkeeping outside it. rawHeadersToObject: traced the array-then-scalar and scalar-then-array duplicate cases to confirm.slice()prevents the encoder from re-reading a mutated caller array.- Ruled out: session reuse across throwing requests in the single-value test — each throwing case gets its own session or is followed only by the empty-array case.
Extended reasoning...
Overview
This PR fixes node:http2 so that an array in a value slot of the raw [name, value, ...] header form is sent as one field per element, matching Node and Bun's own object-form behavior. It touches three files: the raw-pairs walk in H2FrameParser::request() (h2_frame_parser.rs) gains an array branch that mirrors the existing object-form array handling; src/js/node/http2.ts extracts the duplicated sentHeaders-derivation loop from respond() and ClientHttp2Session.request() into rawHeadersToObject(), adding a .slice() on array values so accumulating later duplicates does not mutate the caller's array (which is what the encoder subsequently reads); and ~290 lines of tests are added to node-http2.test.js covering both directions, queued vs. connected, single-value enforcement, strictSingleValueFields: false, sensitive-header never-indexing, and invalid-element rejection.
Security risks
None identified. The change tightens validation (each array element is now run through is_valid_header_value and null/undefined elements are rejected) rather than relaxing it. No new untrusted-length arithmetic or buffer sizing is introduced; encoding still goes through the existing encode_header_into_list.
Level of scrutiny
Medium-high. This is wire-format code in the HTTP/2 encoder — a subtle mistake here corrupts what peers receive. The Rust change is a careful refactor: the per-value logic is hoisted into a local FnMut closure returning JsResult<Option<JSValue>> so the encode-error early-return path (schedule_header_compression_session_error) can bubble out of both the scalar and per-element call sites. I checked that every captured method (encode_header_into_list, handle_received_stream_id, schedule_header_compression_session_error) takes &self, so the closure's shared borrow of this does not conflict with the single_value_index_checked / single_value_headers[idx] bookkeeping that runs between the closure's definition and its calls. The empty-array continue before the single-value mark is a deliberate (and tested) choice matching Node's buildNgHeaderString.
Other factors
The PR is thorough and self-aware — it documents a deliberate divergence from Node (rejecting null/LF elements, consistent with Bun's object form and #37579), the pre-existing HPACK-desync-on-throw issue (#33470), and a landmine in the concurrently open #37568. Test coverage is strong and asserts on the peer-decoded flat header list rather than sentHeaders alone. That said, the combination of a native protocol-path refactor, a documented compat divergence, and cross-PR ordering concerns is exactly the kind of change a maintainer should sign off on rather than an automated approval.
|
On the two points flagged for a maintainer: the one divergence from node (an array element that is null or contains CR/LF/NUL throws |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. The raw-pairs array branch mirrors the object walk's existing shape and the tests cover the wire-visible behaviour end-to-end. Because it restructures the native HPACK header-encoding loop and takes a deliberate Node divergence (rejecting null / CR-LF-NUL array elements rather than stringifying), a maintainer sign-off on that call and on the ordering with #37568 would still be worthwhile.
What was reviewed:
- The new
encode_valueclosure and array/scalar split against the object walk ath2_frame_parser.rs:8448/8484— same single-value bookkeeping, same per-element null check, same never-index propagation. rawHeadersToObject()— theslice()copy prevents the caller-array mutation the tests assert on; both former inline copies (respond/request) are replaced with identical semantics plus the copy.- Empty-array-before-single-value skips the occurrence bit (matches node's
buildNgHeaderString); scalar path is byte-identical to before.
Extended reasoning...
Overview
Three files: src/runtime/api/bun/h2_frame_parser.rs restructures the raw [name, value, ...] header walk inside H2FrameParser::request() so an array in a value slot encodes one HPACK field per element instead of going through Array.prototype.toString; src/js/node/http2.ts extracts the two identical inline sentHeaders-derivation blocks (in ServerHttp2Stream#respond and ClientHttp2Session#request) into rawHeadersToObject() and adds a defensive .slice() so accumulating a later duplicate does not push into the caller's array (which the encoder subsequently reads); ~300 lines of tests in node-http2.test.js covering request/respond, connected/queued, sensitive headers, single-value enforcement, strictSingleValueFields: false, and invalid elements.
Security risks
None identified. Header validation is unchanged for scalars and applied per-element for arrays (the same is_valid_header_value gate and null/undefined rejection the object walk already uses), so this does not open a new injection surface. The single-value rule is enforced on the array as a whole (len > 1 || already_seen), matching the sibling walks.
Level of scrutiny
Medium-high. This is protocol-critical native code on the request/response header path for node:http2, and the diff moves ~40 lines of encoding logic into a closure that is now called from two branches. I compared it line-for-line against the object walk (lines ~8448–8534) and push_promise (lines ~8007–8060), which already have this shape — the new branch is a faithful copy including the Ok(Some(ret)) early-return convention and the compression-error scheduling. The scalar else branch preserves the pre-change behaviour byte-for-byte (single-value check → encode_value(value_js)), so existing callers passing scalars are unaffected.
Other factors
The change carries a deliberate Node.js divergence: null/undefined or CR-LF-NUL-containing array elements throw ERR_HTTP2_INVALID_HEADER_VALUE where Node stringifies them. The PR body argues this matches bun's existing object-form behaviour and #37579's choice for pushStream, which I verified is the pattern at the sibling sites, but per the repo's Node-compat guidance a maintainer should confirm that call. There is also a stated ordering hazard with in-flight #37568 (whose toWireHeaders() would re-join arrays); the new tests would catch it, but landing order matters. The comment-cop bot flags are all resolved (comments were trimmed in a6f9214). Given the native-encoder scope and the compat decision, deferring rather than auto-approving.
They share one event loop, so running them concurrently only stacks every test's wall clock onto the same 5s budget; sequentially each one takes a fraction of a second even under a loaded debug+ASAN build.
…y slot The pre-encode single-value check ran on the object derived from a raw [name, value, ...] list, where a later empty-array slot for a name that already had a value shows up as a second element. Node (and the encoder, which skips an empty array before counting the field) accepts that in either order, so the check now walks the list itself and skips the slots the encoder skips. Tests cover every ordering for request() and respond().
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/js/node/http2.ts`:
- Around line 4032-4040: Update noteSingleValueField() to return seen
immediately for empty array values before checking or recording single-value
headers, while preserving validation for non-empty arrays and scalar values.
Apply the identical empty-array rule in the native object-form encoder, and add
request and response tests covering differently cased duplicate header names.
In `@src/runtime/api/bun/h2_frame_parser.rs`:
- Around line 8907-8951: Copy the raw header name before any JavaScript
execution can re-enter the runtime: update the header-encoding flow around
name_str.to_slice and the encode_value closure to use an owned slice via
to_slice_clone (or otherwise keep the source values alive) before
sensitive_arg.get_truthy or item.to_js_string runs. Ensure validated_name
remains valid when reused after item.to_js_string, and add a regression covering
a toString() implementation that mutates the raw header array.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 96de372d-877a-4216-b442-2c0852c033e1
📒 Files selected for processing (3)
src/js/node/http2.tssrc/runtime/api/bun/h2_frame_parser.rstest/js/node/http2/node-http2.test.js
to_slice() may alias the JSString's storage, and the name is still used after each value's toString() has run, which can drop the list's only reference to that string.
|
Review summary so far, for whoever picks this up:
|
Count the fields a slot will send (0 for [], the length for an array, 1 otherwise) before branching, so the skip and the single-value check exist once instead of once per branch.
The no-payload check called toUpperCase() on whatever :method held, so an array there (a one-element array in either header form, or an empty array following the value in the raw form) threw a TypeError before the block was encoded. Node sends the method the array holds and leaves the stream open; do the same.
|
Heads-up for rebasing: #37945 changes |
…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.
… field respond() and request() sent an array in a value slot of a raw [name, value, ...] list as one field, joined with ",". foldRawHeaders() now folds the list for both. For a list with an array it returns a new list to encode: one pair per element, each element coerced with String() as node does. The native encoder does not change and never receives an array. A list that was expanded is checked before the encoder runs. The single-value rule counts each pair, and an element with CR, LF or NUL throws. The encoder throws for such a value after it has encoded a part of the block. A list without an array takes the same checks as before. request() accepts an array in the :method slot, and it builds its list without Array.prototype.concat.
|
The comments of 2026-08-12 and 2026-08-13 on this PR describe its first version: an array arm in the native walk, and a throw for a
|
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.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟡
src/js/node/http2.ts— nit: Maintainers keep a write that nothing reads any more: the never-index list re-attached to the sliced copy at src/js/node/http2.ts:3579. The only reader of that symbol on the copy was the oldrawHeadersList[sensitiveHeaders]fold, which this diff replaced withsensitiveNamesForCopyat src/js/node/http2.ts:3602. Fix: delete line 3579 and the "carry it over" clause of the comment at 3576, keepingsensitiveNamesForCopyas the single source forheadersObject[sensitiveHeaders]. foldRawHeaders() never looks at symbol keys and the native array walk ignores them too.Why this was flagged
A caller passes a raw list with
list[http2.sensitiveHeaders] = [...]to respond(). The diff reads that list intosensitiveNamesForCopyat src/js/node/http2.ts:3577, then still writes it back onto the slice at src/js/node/http2.ts:3579. On the base branch that write was load-bearing: the old inline fold readrawHeadersList[sensitiveHeaders]from the slice. After the diff, the slice is only consumed by foldRawHeaders() (src/js/node/http2.ts:3600, which reads indexed slots only) and, when no array value is present, by the native request() whose array_iterator never sees symbol keys; the sentHeaders object gets the list directly fromsensitiveNamesForCopyat src/js/node/http2.ts:3602. No behaviour changes for users; the cost is dead code and a stale comment left by the PR that made it dead, which the repository's review rules ask to be removed in the same PR.Verification: Any respond() call with a raw list carrying
list[http2.sensitiveHeaders]. At HEAD src/js/node/http2.ts:3577-3579 the symbol is written back onto the slice. On the base commit the only consumer of that write was the inline fold at base lines 3608-3609, and the diff replaces that withheadersObject[sensitiveHeaders] = sensitiveNamesForCopyat HEAD 3602-3603. So the write at 3579 has no reader. -
🟣
src/js/node/http2.ts— Clients sending cookies as an array in a raw header list get every short cookie field HPACK-indexed after merging, which nghttp2 (and node) never index. foldRawHeaders at src/js/node/http2.ts:3955 stores the copied array as the object value, and buildSensitiveNames at src/js/node/http2.ts:3906 only applies the short-cookie rule when the value is a string, socookienever enters sensitiveNames while the wire list now carries one shortcookiefield per element. Fix: when the cookie value is an array, apply the < 20 rule to each element (markcookienever-index if any element qualifies) so both the raw and object forms match nghttp2; cover["cookie", ["a=1"]]in the never-index test.Why this was flagged
Trigger: request([":path", "/", "cookie", ["a=1", "b=2"]]) or the same in respond()/push headers, the cookie-splitting form RFC 9113 §8.2.3 recommends. foldRawHeaders (src/js/node/http2.ts:3933-3947) copies the array, pushes one
cookie/element pair per element onto the wire list, and at :3955 stores the array copy as object.cookie. buildSensitiveNames (src/js/node/http2.ts:3904-3906) reads that object value, sees typeof cookie !== "string", and does not set map["cookie"]. The native raw walk (src/runtime/api/bun/h2_frame_parser.rs:6590-6597) decides never_index solely from sensitiveNames, so each short cookie element is encoded with indexing into the shared HPACK table. The comment at :3856-3858 states the rule bun intends to mirror (nghttp2 never indexes cookie values < 20 chars); node gets it from nghttp2 for every element. On the base branch the array was sent as one joined field, so this PR is what first produces the per-element short cookie fields the rule targets. Remedy: apply the length rule to array elements in buildSensitiveNames.Verification: Trigger: a
cookieheader whose value slot is an array.foldRawHeaders(src/js/node/http2.ts:3955-3957) stores the copied array asobject[key];buildSensitiveNames(src/js/node/http2.ts:3904-3906) applies the short-cookie never-index rule only whentypeof cookie === "string", so an array value never setsmap["cookie"]. However the base branch already fails by the same route.
…er runs foldRawHeaders() always builds a new list, so a queued request() does not read a change the caller makes after the call. assertRawHeaderList() runs on every raw list: the single-value rule per field and the value bytes the encoder refuses, through the ERR_HTTP2_INVALID_HEADER_VALUE helper. A bad :path is left to the check that reports it on the stream, which now reads the first :path field of the list. A cookie array marks the name never-indexed when an element is shorter than 20 bytes, as nghttp2 does per field. The never-index list is no longer written back onto the copy of the list in respond().
…ck is encoded The object form and pushStream() throw ERR_HTTP2_INVALID_HEADER_VALUE for a null or undefined element. A raw list now throws the same, from the call, before a field reaches the encoder. The cookie never-index rule for an array goes back to the scalar check: the per-field rule belongs in the encoder.
|
Pushed 05cb9ea, 704626f and 8de7a9f for the review findings.
|
The folded object keeps every slot, so an undefined :method slot next to the default GET gave an array, and the request was not ended. Node reads the method from the scan of the list. The same now.
Stacked on #44804. Merge #44804 first.
Problem
[name, value, ...]list ofrespond()andrequest(), an array value goes out as one field joined with,.respond([":status", 200, "set-cookie", ["a=1", "b=2"]])sendsset-cookie: a=1,b=2. Node v26.3.0 sends one field per element.h2_frame_parser.rs:6588).Fix
foldRawHeaders()builds the list to encode for both methods: one pair per element of an array. Native code is unchanged.assertRawHeaderList()checks every raw list before the encoder runs: the single-value rule per field, then CR, LF or NUL in a value. A bad:pathis reported on the stream, as in Node.nullelement throwsERR_HTTP2_INVALID_HEADER_VALUEfrom the call, as the object form andpushStream()do (node:http2: send array-valued pushStream() headers as one field per element #37579).test/js/node/http2/node-http2-header-list.test.ts(13 new tests fail on node:http2: copy the array values of a raw header list in respond() and request() #44804). Alsonode-http2.test.jsand 255 upstreamtest-http2-*.js. Self-reviewed: 10 concerns raised, 9 addressed (the one left is in Notes).Background
nullelement then throws after a partial encode.Downsides
nullelement throws where Node sends the textnull. Bun chose the throw in node:http2: send array-valued pushStream() headers as one field per element #37579.cookiewith an array of short elements is not never-indexed. nghttp2 decides that per field, in the encoder. A separate change.Notes
Repro
The fields after
:status, as the client decoded them:arrayset-cookie: a=1,set-cookie: b=2set-cookie: a=1,b=2set-cookie: a=1,set-cookie: b=2nullx-n: a,x-n: nullx-n: a,respond()throwscrlfx-session: sid=malletrespond()throwsrespond()throwsafter(the next response)x-session: sid=bobx-session: sid=bobx-session: sid=bobThe
crlfrow is a difference from Node that main has too: Node drops the element, Bun throwsERR_HTTP2_INVALID_HEADER_VALUE. Thenullrow is the rule of the object form and ofpushStream().Changes after review
foldRawHeaders()always builds a new list. A caller that changes its own list after a queuedrequest()does not change what is sent.["content-type", "text/html", "content-type", undefined]is accepted, as in Node. Before, the outcome depended on whether another slot held an array.:pathis read from the list that is encoded.[":path", "/a\n"],[":path", "/a\n", "x", ["b"]]and[":path", ["/a\n"]]all reportNGHTTP2_PROTOCOL_ERRORon the stream, as in Node.$ERR_HTTP2_INVALID_HEADER_VALUE.nullorundefinedelement throwsERR_HTTP2_INVALID_HEADER_VALUEfrom the call. An earlier head of this PR sent it as text, as Node does. The merged node:http2: send array-valued pushStream() headers as one field per element #37579 chose the throw forpushStream()to match the object form, so the raw list follows that rule. The testrequest() and respond() refuse a null or undefined element before the block is encodedcovers it.cookieonce per short element on the receiver. That needs the rule inencode_header_into_list()(h2_frame_parser.rs), for every form at once, in a separate change.respond(). Nothing read it.request()decides the method of a raw list from its last:methodslot, as node does.[":method", undefined, ":path", "/"]sends GET with END_STREAM. Before, the folded object held["GET", undefined]and the request was not ended.Rules for a raw list
A queued
request()with a bad element throws from the call, not on the stream. Node encodes the block insiderequest()and throws from the call too.An array of a pseudo-header with two or more elements: with
strictSingleValueFields: falsethe elements are joined into one field, as in Node. With the rule on, the pairs are rejected withERR_HTTP2_HEADER_SINGLE_VALUE.The single-value check counts each pair with a value. A
nullvalue in a slot is an occurrence, as in Node. Anundefinedvalue and an empty array are not.An element is coerced with
String()inside the call. AtoString()that throws makesrequest()throw at the call on a connecting session too.Known difference:
respond([":status", "200", ":status", []])throwsERR_HTTP2_STATUS_INVALID. Main and Node v26.3.0 throwERR_HTTP2_HEADER_SINGLE_VALUE. Each throws before anything is encoded.Self-review
strictSingleValueFields: false, the elements of a pseudo-header array are joined for a raw list (Node's rule), while the object form still sends one pseudo field per element (native, unchanged). The object form is the subject of node:http2: normalize outbound header objects like node #41614.Measurements
js/node/http2.js169,374 B to 171,300 B (+1,926 B). No native change, no new host function.respond()allocates one more Array (the list to encode, next to the slice it already made).request()allocates the same number as before when it adds a pseudo-header (the oldconcatarray is gone) and one more when the caller gave every pseudo-header. Object form: no new allocation.valgrind).Not changed here
nullelement. If it lands, the raw list should follow it.[]in the object form counts as an occurrence of a single-value field (node:http2: an empty array value is not an occurrence of a single-value header #37945).request()is read when the session connects (related: node:http2: coerce outbound header values before taking a stream id #37568).null. Node sendsnull.Suites run with the debug build
test/js/node/http2/node-http2-header-list.test.ts: 19 pass. On node:http2: copy the array values of a raw header list in respond() and request() #44804, the 13 tests of this PR fail.test/js/node/http2/node-http2.test.js: 393 pass, 6 skip.test/js/node/test/parallel/test-http2-*.js: 255 pass (test-http2-forget-closed-streams.jsnot run, it needs about 4 minutes with the debug build).[review] gate passed · iteration 2 · 3 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 1 rejected · iteration 2
evidence per changed file
root cause · written by the author bot
The raw flat array header path in the native request encoder, which also serves respond(), converted each value slot straight to a string, so an array value was stringified into a single comma-joined field instead of one field per element as the object form and node already produce. The fix makes that walk compute the number of fields a slot will emit once (none for an empty array, the array length, or one for a scalar), skip empty arrays, apply the single-value header check against that count, and convert, validate and HPACK-encode each element as its own field. The JavaScript side now als…