Skip to content

node:http2: skip undefined-valued properties in outbound header blocks - #37953

Closed
robobun wants to merge 6 commits into
mainfrom
farm/fe371d5b/http2-skip-undefined-header-values
Closed

robobun wants to merge 6 commits into
mainfrom
farm/fe371d5b/http2-skip-undefined-header-values

Conversation

@robobun

@robobun robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • In the object form of an outbound header block, a property whose value is undefined is rejected. stream.respond({ ":status": 200, "content-type": maybeUndefined }), additionalHeaders() and sendTrailers() throw ERR_HTTP2_INVALID_HEADER_VALUE (Invalid value for header "content-type") synchronously; session.request() returns a stream that emits the same error a tick later.
  • Node skips such a property before looking at it (buildNgHeaderString in lib/internal/http2/util.js: if (value === undefined || key === '') continue;), so it is not sent, its name is never validated, and it does not count as an occurrence of a single-value field. Passing possibly-undefined values and relying on that is common in node code.
  • The client case also breaks the session: H2FrameParser::request() encodes the pseudo-headers in its first pass and throws in the second, so the connection's HPACK encoder is one block ahead of the peer and the next request on the session fails too.
  • Cause, native (src/runtime/api/bun/h2_frame_parser.rs): the object walks in request() (behind request(), respond() and additionalHeaders()) and send_trailers() throw on is_undefined_or_null() after validating the name; push_promise() skips undefined, but also only after validating the name. The raw [name, value, ...] walk already skipped undefined slots, so the two forms disagreed.
  • Cause, JS (src/js/node/http2.ts): the checks that run before encoding count the property as well. assertSingleValueHeaders() throws ERR_HTTP2_HEADER_SINGLE_VALUE for { "content-type": undefined, "Content-Type": "text/plain" }, request()'s name validation throws for { ":bogus": undefined }, additionalHeaders()'s pseudo-header loop likewise, pushStream()'s assertNoConnectionHeaders() for { connection: undefined }, and the no-payload check rejects { "content-length": undefined } on a GET.

Fix

  • The three native object walks test iter.value.is_undefined() next to the existing empty-name check at the top of the loop, before name and pseudo-header validation and before single-value accounting, which is the position node's skip has. The scalar branch in request() loses its guard, which this makes unconditional.
  • null is left exactly as it was on every path: the narrowed checks are is_null(), so request()/respond()/additionalHeaders()/sendTrailers() still throw on it and pushStream() still skips it. Node stringifies it; bun rejects null elements on purpose (node:http2: send array-valued pushStream() headers as one field per element #37579), and whether scalars should follow node or the elements is a separate decision from this one, so nothing about it changes here.
  • send_trailers(): when every field was skipped it ends the stream with the same empty DATA+END_STREAM frame noTrailers() sends for sendTrailers({}), instead of a zero-length HEADERS block. That is what node's SubmitTrailers does for an empty list, and parity is the only reason for it: the peer then sees the stream end without a 'trailers' event, as it does with a node server. (The comment next to the JS short-cut used to say that nghttp2 peers reject a zero-length trailer block; node v26's client and curl with nghttp2 1.64 both accept one, checked against the current release, so that comment is gone.) node:http2: end the stream with an empty DATA frame when sendTrailers() encodes no fields #37951 is the standalone fix for this rule, with wire-level tests, since sendTrailers({ x: [] }) already reaches an empty block on main; the skip adds another way to get there, so this PR carries the same few lines inline, and whichever of the two lands second drops its copy.
  • The five JS pre-checks skip undefined-valued properties. They exist to reject a block before anything reaches the encoder, so each of them has to agree with the encoder about which properties are fields: one that still rejected would keep the bug for that call, and the same mismatch in the other direction is the HPACK desync above, so all of them change together with the encoder.
  • The rule lives in the encoders rather than in a JS pass that strips the properties first because the encoders are what every entry point shares (pushStream() even adds an undefined :authority of its own after its checks have run, which is why push_promise() already skipped it), because stripping would mean deleting properties from the copied object that backs sentHeaders (node keeps the undefined-valued key there, and the file avoids delete on these objects for the dictionary-mode reason noted in it), and because the pre-checks have to be kept in step with the encoder either way.
  • Http2ServerResponse#writeEarlyHints() was relying on the native rejection: node's compat layer validates each hint itself and its test-http2-compat-write-early-hints-invalid-header.js expects ERR_HTTP2_INVALID_HEADER_VALUE for { "x-custom": undefined }, which this change would otherwise have started sending. It now does what node's does: assertValidHeader() plus the token check on the trimmed, lowercased name, then writeInformation(). This is the one function here that gains rejections as well as losing some, all of them node's (second table below): a :status hint throws ERR_HTTP2_PSEUDOHEADER_NOT_ALLOWED instead of being sent, and a bad hint throws even when the link list is empty or the response headers are already out, where the old code returned false; on the other side { " X-Hint ": "v" } is sent as x-hint (it used to throw), a Link hint is sent alongside link (it used to be dropped), and writeInformation(103, { x: undefined }) passes the value through to additionalHeaders() (it used to throw).
  • Why this is right: every changed row in both tables below now produces node's result. For the stream-level builders the change only removes rejections (nothing that was sent before is sent differently, and no call throws that did not); writeEarlyHints() is node's current code, and the rows that still differ from node all involve null or array elements, which are unchanged on every path.
  • Verification: seven tests in test/js/node/http2/node-http2-undefined-header-values.test.js, one per block builder plus the all-skipped trailers case and the compat boundary. They assert on the field list the peer decoded from the wire and fail if either session reports an error; the expected values were produced by running the same blocks under node v26.3.0. Each builder's block includes a pseudo-header name with an undefined value (in the trailers block that is the case where the skip, not the name check, has to win, since trailers reject every pseudo-header) and the single-value pair spelled undefined-then-defined; request() (which runs the shared JS pre-check), pushStream() and sendTrailers() (the two builders with their own native accounting) also send the pair in the other order, which is the only input that tells a skip placed before the occurrence check from one placed after it. The compat test pins the writeEarlyHints() rejections, the link: [] ordering and the shape of sentInfoHeaders. All seven fail on main (six on the rejection, the compat one on writeInformation()) and pass with the fix.
  • The tests are in their own file rather than appended to node-http2.test.js: that file has a dozen pre-existing tests that take 3-5 s on a debug build, so running the whole of it under ASAN on a busy machine fails a few of them on every run (tracked separately, it is not specific to this change), and node:http2: an empty array value is not an occurrence of a single-value header #37945, node:http2: end the stream with an empty DATA frame when sendTrailers() encodes no fields #37951 and node:http2: send array values in raw-form header lists as one field per element #37796 all add their tests at the same spot in it, so keeping these apart also removes that conflict. The file is self-contained; nothing in node-http2.test.js changes.
  • bun bd test test/js/node/http2/: 472 pass, 0 fail. All 256 ported test/js/node/test/parallel/test-http2-*.js files pass with the fix (test-http2-compat-write-early-hints-invalid-header.js is the one that caught writeEarlyHints()).
  • Open PRs nearby: node:http2: end the stream with an empty DATA frame when sendTrailers() encodes no fields #37951 (above). node:http2: an empty array value is not an occurrence of a single-value header #37945 (an empty array is not an occurrence of a single-value field) edits the lines next to these in assertSingleValueHeaders() and the array branches; the two rules are independent and compose in either order, the overlap is textual. node:http2: fix connection-specific and pseudo-header validation on outbound header blocks #33458 carries the same undefined skip in assertNoConnectionHeaders(). node:http2: coerce outbound header values before taking a stream id #37568 passes primitives through to the encoder untouched and node:http2: send array values in raw-form header lists as one field per element #37796's raw-form pre-check already skips undefined slots, so both keep working with this.

Background

  • node:http2 takes an outbound header block either as an object ({ name: value }, an array value meaning one field per element) or as a flat [name, value, ...] list. This PR is about the object form; request(), respond(), additionalHeaders(), pushStream() and sendTrailers() all accept it.
  • In bun the block is encoded natively by H2FrameParser, which walks the object, validates each field and HPACK-encodes it as it goes. HPACK keeps a table per connection that the peer's decoder mirrors entry for entry, so a block that is rejected after some of its fields were encoded leaves the connection's table ahead of the peer's and every later block is misdecoded. The JS layer therefore repeats some of the validation before calling into native (assertSingleValueHeaders() and friends); those checks are only correct if they classify properties exactly as the encoder does.
  • Single-value fields are the ones node refuses to send twice (content-type, the pseudo-headers, ...); a second occurrence, or a multi-element array, is ERR_HTTP2_HEADER_SINGLE_VALUE.
  • Trailers are a final HEADERS frame carrying END_STREAM. When the trailer list comes out empty, node ends the stream with an empty DATA frame carrying END_STREAM instead (Http2Stream::SubmitTrailers in node_http2.cc), so the peer gets no 'trailers' event; bun's noTrailers() path sends the same frame.
  • Http2ServerResponse (the node:http-shaped compat layer) validates header values when they are set, and rejects undefined and null there, in node as well (setHeader(), writeEarlyHints()); the stream-level API underneath is the one that skips undefined. The compat test in this PR pins that boundary.
Behavior matrices: node v26.3.0 / main / this PR (stream-level builders: 50 calls, 20 change; compat layer: 7 calls, 6 change; every change is to node's result)

Each row is one call made against an http2.createServer() / http2.connect() pair on a fresh session; the listed fields are the x-* and content-type fields the peer decoded. The [undefined] / [null] rows are array elements and the :status null row is the JS status default; none of those change.

call node v26.3.0 main this PR
request obj: x-v undefined server got ["x-ok=1"] request() returned, stream error ERR_HTTP2_INVALID_HEADER_VALUE server got ["x-ok=1"]
request obj: x-v null server got ["x-v=null","x-ok=1"] request() returned, stream error ERR_HTTP2_INVALID_HEADER_VALUE unchanged
request obj: x-v '' server got ["x-v=","x-ok=1"] server got ["x-v=","x-ok=1"] unchanged
request obj: x-v 0 server got ["x-v=0","x-ok=1"] server got ["x-v=0","x-ok=1"] unchanged
request obj: x-v false server got ["x-v=false","x-ok=1"] server got ["x-v=false","x-ok=1"] unchanged
request obj: x-v [] server got ["x-ok=1"] server got ["x-ok=1"] unchanged
request obj: x-v [undefined] server got ["x-v=undefined","x-ok=1"] request() returned, stream error ERR_HTTP2_INVALID_HEADER_VALUE unchanged
request obj: x-v [null] server got ["x-v=null","x-ok=1"] request() returned, stream error ERR_HTTP2_INVALID_HEADER_VALUE unchanged
request obj: :bogus undefined server got ["x-ok=1"] request() threw ERR_HTTP2_INVALID_PSEUDOHEADER server got ["x-ok=1"]
request obj: 'bad name' undefined server got ["x-ok=1"] request() threw ERR_INVALID_HTTP_TOKEN server got ["x-ok=1"]
request obj: content-type undefined + Content-Type set server got ["content-type=text/x","x-ok=1"] request() threw ERR_HTTP2_HEADER_SINGLE_VALUE server got ["content-type=text/x","x-ok=1"]
request obj: content-type set + Content-Type undefined server got ["content-type=text/x","x-ok=1"] request() threw ERR_HTTP2_HEADER_SINGLE_VALUE server got ["content-type=text/x","x-ok=1"]
request obj: content-type null server got ["content-type=null","x-ok=1"] request() returned, stream error ERR_HTTP2_INVALID_HEADER_VALUE unchanged
request obj: :path undefined server got ["x-ok=1"] server got ["x-ok=1"] unchanged
request obj: :authority undefined server got ["x-ok=1"] server got ["x-ok=1"] unchanged
request raw: x-v undefined server got ["x-ok=1"] server got ["x-ok=1"] unchanged
request raw: x-v null server got ["x-v=null","x-ok=1"] server got ["x-ok=1"] unchanged
request raw: content-type undefined then set server got ["content-type=text/x","x-ok=1"] server got ["content-type=text/x","x-ok=1"] unchanged
respond obj: x-v undefined client got [":status=200","x-ok=1"] respond() threw ERR_HTTP2_INVALID_HEADER_VALUE client got [":status=200","x-ok=1"]
respond obj: x-v null client got [":status=200","x-v=null","x-ok=1"] respond() threw ERR_HTTP2_INVALID_HEADER_VALUE unchanged
respond obj: x-v '' client got [":status=200","x-v=","x-ok=1"] client got [":status=200","x-v=","x-ok=1"] unchanged
respond obj: x-v [] client got [":status=200","x-ok=1"] client got [":status=200","x-ok=1"] unchanged
respond obj: x-v [null] client got [":status=200","x-v=null","x-ok=1"] respond() threw ERR_HTTP2_INVALID_HEADER_VALUE unchanged
respond obj: :bogus undefined client got [":status=200","x-ok=1"] respond() threw ERR_HTTP2_INVALID_PSEUDOHEADER client got [":status=200","x-ok=1"]
respond obj: 'bad name' undefined client got [":status=200","x-ok=1"] respond() threw ERR_INVALID_HTTP_TOKEN client got [":status=200","x-ok=1"]
respond obj: content-type undefined + Content-Type set client got [":status=200","content-type=text/x","x-ok=1"] respond() threw ERR_HTTP2_HEADER_SINGLE_VALUE client got [":status=200","content-type=text/x","x-ok=1"]
respond obj: content-type null client got [":status=200","content-type=null","x-ok=1"] respond() threw ERR_HTTP2_INVALID_HEADER_VALUE unchanged
respond obj: :status undefined client got [":status=200","x-ok=1"] client got [":status=200","x-ok=1"] unchanged
respond obj: :status null client got [":status=200","x-ok=1"] respond() threw ERR_HTTP2_STATUS_INVALID unchanged
respond obj: only undefined (no :status) client got [":status=200"] respond() threw ERR_HTTP2_INVALID_HEADER_VALUE client got [":status=200"]
respond raw: x-v undefined client got [":status=200","x-ok=1"] client got [":status=200","x-ok=1"] unchanged
respond raw: x-v null client got [":status=200","x-v=null","x-ok=1"] client got [":status=200","x-ok=1"] unchanged
additionalHeaders: x-v undefined client got info [":status=102","x-ok=1"] additionalHeaders() threw ERR_HTTP2_INVALID_HEADER_VALUE client got info [":status=102","x-ok=1"]
additionalHeaders: x-v null client got info [":status=102","x-v=null","x-ok=1"] additionalHeaders() threw ERR_HTTP2_INVALID_HEADER_VALUE unchanged
additionalHeaders: :bogus undefined client got info [":status=102","x-ok=1"] additionalHeaders() threw ERR_HTTP2_INVALID_PSEUDOHEADER client got info [":status=102","x-ok=1"]
additionalHeaders: content-type undefined + Content-Type set client got info [":status=102","content-type=text/x","x-ok=1"] additionalHeaders() threw ERR_HTTP2_HEADER_SINGLE_VALUE client got info [":status=102","content-type=text/x","x-ok=1"]
pushStream: x-v undefined client got push ["x-ok=1"] client got push ["x-ok=1"] unchanged
pushStream: x-v null client got push ["x-v=null","x-ok=1"] client got push ["x-ok=1"] unchanged
pushStream: x-v [null] client got push ["x-v=null","x-ok=1"] pushStream() callback ERR_HTTP2_INVALID_HEADER_VALUE unchanged
pushStream: content-type undefined + Content-Type set client got push ["content-type=text/x","x-ok=1"] pushStream() sync ERR_HTTP2_HEADER_SINGLE_VALUE client got push ["content-type=text/x","x-ok=1"]
pushStream: content-type null client got push ["content-type=null","x-ok=1"] client got push ["x-ok=1"] unchanged
sendTrailers: x-v undefined + x-ok client trailers event: ["x-ok=1"] rstCode=0 stream error ERR_HTTP2_STREAM_ERROR (sendTrailers threw) client trailers event: ["x-ok=1"] rstCode=0
sendTrailers: x-v null + x-ok client trailers event: ["x-v=null","x-ok=1"] rstCode=0 stream error ERR_HTTP2_STREAM_ERROR (sendTrailers threw) unchanged
sendTrailers: only x-v undefined client trailers event: none rstCode=0 stream error ERR_HTTP2_STREAM_ERROR (sendTrailers threw) client trailers event: none rstCode=0
sendTrailers: only x-v [] client trailers event: none rstCode=0 client trailers event: [] rstCode=0 client trailers event: none rstCode=0
sendTrailers: {} client trailers event: none rstCode=0 client trailers event: none rstCode=0 unchanged
sendTrailers: ':status' undefined + x-ok client trailers event: ["x-ok=1"] rstCode=0 sendTrailers() threw ERR_HTTP2_INVALID_PSEUDOHEADER client trailers event: ["x-ok=1"] rstCode=0
sendTrailers: 'bad name' undefined + x-ok client trailers event: ["x-ok=1"] rstCode=0 stream error ERR_HTTP2_STREAM_ERROR (sendTrailers threw) client trailers event: ["x-ok=1"] rstCode=0
sendTrailers: content-type undefined + Content-Type set client trailers event: ["content-type=text/x"] rstCode=0 stream error ERR_HTTP2_STREAM_ERROR (sendTrailers threw) client trailers event: ["content-type=text/x"] rstCode=0
sendTrailers: content-type null client trailers event: ["content-type=null"] rstCode=0 stream error ERR_HTTP2_STREAM_ERROR (sendTrailers threw) unchanged

Http2ServerResponse rows (7 calls, 6 change, again every change is to node's result). link is "</s.css>; rel=preload"; the listed fields are the :status, x-* and link fields of the informational block the client received.

call (compat layer) node v26.3.0 main this PR
writeEarlyHints {link, x-hint: undefined} threw ERR_HTTP2_INVALID_HEADER_VALUE; client got no info block threw ERR_HTTP2_INVALID_HEADER_VALUE; client got no info block unchanged
writeEarlyHints {link, ':status': 103} threw ERR_HTTP2_PSEUDOHEADER_NOT_ALLOWED; client got no info block returned true; client got [":status=103 link=</s.css>; rel=preload"] threw ERR_HTTP2_PSEUDOHEADER_NOT_ALLOWED; client got no info block
writeEarlyHints {link: [], x-hint: undefined} threw ERR_HTTP2_INVALID_HEADER_VALUE; client got no info block returned false; client got no info block threw ERR_HTTP2_INVALID_HEADER_VALUE; client got no info block
writeEarlyHints after writeHead, {link, x-hint: undefined} threw ERR_HTTP2_INVALID_HEADER_VALUE; client got no info block returned false; client got no info block threw ERR_HTTP2_INVALID_HEADER_VALUE; client got no info block
writeEarlyHints {link, ' X-Hint ': 'h'} returned true; client got [":status=103 x-hint=h link=</s.css>; rel=preload"] threw ERR_INVALID_HTTP_TOKEN; client got no info block returned true; client got [":status=103 x-hint=h link=</s.css>; rel=preload"]
writeEarlyHints {link, Link: '</b.css>; rel=preload'} returned true; client got [":status=103 link=</b.css>; rel=preload link=</s.css>; rel=preload"] returned true; client got [":status=103 link=</s.css>; rel=preload"] returned true; client got [":status=103 link=</b.css>; rel=preload link=</s.css>; rel=preload"]
writeInformation(103, {x-undefined: undefined, x-info: 'i'}) returned true; client got [":status=103 x-info=i"] threw ERR_HTTP2_INVALID_HEADER_VALUE; client got no info block returned true; client got [":status=103 x-info=i"]

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on main (debug build at 165dc9f) and on bun 1.4.0 against node v26.3.0 with the matrices in the PR description: request(), respond(), additionalHeaders() and sendTrailers() reject an undefined-valued property in the object form, pushStream() validates its name first; node skips it outright.
  • Fix is in this PR (head 07492b1, rebased onto current main; the last commit only moves the tests into their own file). The seven tests in test/js/node/http2/node-http2-undefined-header-values.test.js fail on main and pass with the change; test/js/node/http2/ and all 256 ported test-http2-*.js files pass with it.
  • Follow-up commits after self-review: the stale "strict peers reject an empty trailer block" comment is gone (node's client and curl accept one; the fallback exists for parity with node's wire output), the compat test pins the rejections writeEarlyHints() gains from node's validation order plus sentInfoHeaders, the request()/pushStream()/sendTrailers() tests also send the single-value pair defined-then-undefined, and the comments flagged by comment-cop were cut to one line each (the request() one is back to its pre-PR text).
  • null values are deliberately unchanged on every path. node:http2: end the stream with an empty DATA frame when sendTrailers() encodes no fields #37951 is the standalone fix for the empty-trailers fallback carried inline here (whichever lands second drops its copy); node:http2: an empty array value is not an occurrence of a single-value header #37945 is the neighbouring empty-array change; both compose with this one.
  • CI on the first push (build 93693): every job that ran passed (179 of 181, macOS 14 included); the two macOS 26 test jobs were cancelled before an agent picked them up, as on every build in that window. The later pushes only touch tests and comments.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ed4e6765-4856-4760-8cd2-d7129034059d

📥 Commits

Reviewing files that changed from the base of the PR and between f426a8e and c911973.

📒 Files selected for processing (3)
  • src/js/node/http2.ts
  • src/runtime/api/bun/h2_frame_parser.rs
  • test/js/node/http2/node-http2.test.js

Walkthrough

HTTP/2 header processing now skips undefined values across request, response, push-promise, informational, and trailer paths. Empty trailers end streams with an empty DATA frame. Tests cover transmission, validation, and session behavior.

Changes

HTTP/2 header handling

Layer / File(s) Summary
JavaScript header validation
src/js/node/http2.ts
Undefined header values are skipped across request, response, pseudo-header, connection, single-value, and content-length validation. writeEarlyHints() validates headers before calling writeInformation().
Native encoding and trailer completion
src/runtime/api/bun/h2_frame_parser.rs, src/js/node/http2.ts
Native processing skips undefined properties while retaining null validation. Empty trailer submissions clear trailer state and send an empty terminal DATA frame.
HTTP/2 behavior tests
test/js/node/http2/node-http2.test.js
Tests cover undefined-valued headers for requests, responses, additional headers, push streams, trailers, early hints, and informational responses. Empty trailer submissions are also covered.

Possibly related PRs

Suggested reviewers: cirospaciari, jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely describes the main change: skipping undefined-valued properties in outbound HTTP/2 header blocks.
Description check ✅ Passed The description is detailed and covers the change, rationale, behavior, testing, and compatibility effects, although it uses different headings than the template.

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.

LGTM — well-scoped Node compat fix with thorough wire-level tests.

What was reviewed:

  • The three native object walks now skip undefined before name validation, matching node's buildNgHeaderString; null handling is unchanged on every path.
  • The empty-trailers early return in send_trailers() mirrors no_trailers() exactly (wait_for_trailers = false + empty DATA/END_STREAM via send_data); &mut stream deref-coerces GuardedStream → &mut Stream correctly.
  • All five JS pre-checks that mirror the encoder's classification skip undefined in step; writeEarlyHints() now validates hints itself (assertValidHeader + token check on trimmed/lowercased name) and delegates to writeInformation(), preserving the compat-layer rejection that test-http2-compat-write-early-hints-invalid-header.js pins.
  • Seven new tests assert on peer-decoded field lists and wire error events to reject; pushedHeaderBlocks helper exists at line 3783.
Extended reasoning...

Overview

This PR aligns Bun's node:http2 outbound-header-object handling with Node's: a property whose value is undefined is skipped before its name is validated or counted, rather than rejected. It touches three files: src/runtime/api/bun/h2_frame_parser.rs (three native object walks + an empty-trailers fallback), src/js/node/http2.ts (five JS pre-checks + a writeEarlyHints() refactor), and seven new tests in test/js/node/http2/node-http2.test.js.

Security risks

None. The change only removes rejections of undefined-valued properties (which are then not sent at all); nothing that was previously sent is sent differently, and no call throws that did not before. null continues to be rejected exactly as on main. Header name/value validation for properties that are sent is unchanged.

Level of scrutiny

Medium. This is Node-compat behavior in the http2 header-encoding path, which has an HPACK-desync failure mode if the JS pre-checks and native encoder disagree — but the PR explicitly addresses that by updating all five JS pre-checks in lockstep with the three native walks, and the tests assert on what the peer actually decoded (so a desync would fail them). The 49-row behavior matrix in the description shows every changed row moves to node v26.3.0's result. The send_trailers() empty-case addition is a byte-for-byte copy of no_trailers()'s body against a GuardedStream (which DerefMuts to &mut Stream).

Other factors

  • Tests are well-constructed: they wire every session/stream error to reject the awaited promise, assert on rawHeaders decoded from the wire (not the sender's view), cover the raw-array form alongside the object form, and include a follow-up request to prove the session's HPACK table stayed in sync.
  • The writeEarlyHints() refactor was needed because it was relying on the native rejection this PR removes; the new form matches Node's compat layer (validate each hint, trim+lowercase the name, delegate to writeInformation()) and is pinned by both the new compat test and the existing ported test-http2-compat-write-early-hints-invalid-header.js.
  • bun bd test test/js/node/http2/ reports 472 pass / 0 fail and all 256 ported test-http2-*.js pass per robobun's status comment.
  • The description explicitly scopes out null (unchanged, deliberately) and notes textual overlap with #37945/#33458, both of which compose with this.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:05 PM PT - Aug 12th, 2026

@robobun, your commit c911973 is building: #93693

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:05 PM PT - Aug 12th, 2026

❌ @robobun, your commit c911973 has some failures in Build #93693 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 37953

That installs a local version of the PR into your bun-37953 executable, so you can run:

bun-37953 --bun

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:31 AM PT - Aug 13th, 2026

❌ @robobun, your commit 07492b1 has 1 failures in Build #94645 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37953

That installs a local version of the PR into your bun-37953 executable, so you can run:

bun-37953 --bun

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Overlap note: #37951 (opened a little earlier) is a standalone fix for the all-empty trailer block (sendTrailers({ x: [] }) and friends) and adds the same encoded_headers.is_empty() rule at the same spot in send_trailers(), factored into a send_no_trailers() helper shared with no_trailers(), plus wire-level tests for it. The rules are identical; whichever of the two lands second just drops its copy of that hunk (the undefined skip itself is independent of it).

Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated

@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.

I reviewed this PR and didn't find any bugs. Because it rewrites writeEarlyHints() (adding rejections as well as removing them), touches the HPACK encoder's validate-vs-skip ordering across three native loops plus five JS pre-checks, and overlaps with #37951 on the empty-trailers fallback, a human look is still worthwhile. There are also seven unresolved comment-cop flags on comment length that a maintainer should call on.

Checked: the three native iter.value.is_undefined() skips sit before name validation and single-value accounting, matching node's buildNgHeaderString position; null stays on its old paths (is_null() narrowing only). Verified assertValidHeader() rejects undefined/null and writeInformation() carries the headersSent/closed guard the old code had. The encoded_headers.is_empty() fallback mirrors no_trailers()'s DATA+END_STREAM. Tests wire session/stream errors to reject and assert on the peer's decoded field list.

Extended reasoning...

Overview

Node.js compat fix for node:http2: properties with undefined values in outbound header objects are now skipped before name validation and single-value accounting, matching node's buildNgHeaderString. Three files: src/runtime/api/bun/h2_frame_parser.rs (three object-walk loops in send_trailers/push_promise/request hoist iter.value.is_undefined() to the top; remaining checks narrowed to is_null(); send_trailers gains an empty-block fallback to DATA+END_STREAM), src/js/node/http2.ts (five JS pre-checks skip undefined; writeEarlyHints() rewritten to validate hints via assertValidHeader + checkIsHttpToken and delegate to writeInformation()), and seven new tests in test/js/node/http2/node-http2.test.js.

Security risks

None identified. The change loosens validation only for undefined (dropping the field, never sending it), which cannot inject header content. null handling is unchanged. The writeEarlyHints() rewrite tightens validation (pseudo-headers now rejected, invalid values rejected before the link check) and trims/lowercases names before the token check; assertValidHeader still rejects space-containing names.

Level of scrutiny

High. HPACK encoding is stateful per-connection: the JS pre-checks and the native encoder must agree on which properties count as fields, or a half-encoded block desyncs the session (the PR description itself calls this out as a bug on main). The writeEarlyHints() change is a behavior rewrite — it now throws in cases that used to return false (bad hint with link: [], or after headersSent) and accepts cases that used to throw (padded names). Every changed row in the 50-call matrix is claimed to match node v26.3.0, but that's exactly the kind of claim a maintainer familiar with this file should verify.

Other factors

  • Seven unresolved comment-cop inline flags from github-actions on comment length; the second commit (70ca4a97) reworded some but the flags remain on the current diff. A maintainer should decide whether the comments (which explain node parity, not workarounds) are acceptable.
  • Acknowledged overlap with #37951 on the send_trailers empty-block fallback; merge order needs coordinating.
  • Test coverage is thorough: seven tests, each block builder covered, failure paths wired to reject, peer-decoded fields asserted, session-health check after the request that used to desync HPACK. All 256 ported test-http2-* files reported passing.
  • claude-labeled (AI-authored) PR touching a ~9000-line native file and a ~6000-line JS file; the changes are surgical but the surrounding code is dense enough that a maintainer sign-off is appropriate.

Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated

@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.

I reviewed this PR and didn't find any bugs. The change is well-scoped and the behavior matrix in the description is thorough, but since it touches native HPACK encoding across three object-walk loops and rewrites writeEarlyHints() with behavior changes in both directions, a human look would still be worthwhile.

What was reviewed:

  • The three native iter.value.is_undefined() hoists in send_trailers/push_promise/request — the value is populated by iter.next() with include_value: true, so reading it before name validation is sound; the narrowed is_null() checks preserve the existing null behavior on every path.
  • The encoded_headers.is_empty() fallback matches no_trailers() line-for-line (wait_for_trailers = false + empty DATA with END_STREAM); noted the overlap with #37951.
  • The five JS pre-checks (assertSingleValueHeaders, assertNoConnectionHeaders, request()'s name loop, additionalHeaders()' pseudo-header loop, the no-payload content-length check) each skip only undefined, keeping them in step with the encoder.
  • writeEarlyHints() now routes through assertValidHeader + writeInformation(); verified assertValidHeader rejects undefined/null/pseudo-headers so the compat-layer boundary the tests pin holds.
Extended reasoning...

Overview

This PR aligns Bun's node:http2 outbound header handling with Node's buildNgHeaderString: a property whose value is undefined is skipped before its name is validated or counted, rather than rejected. It touches three files: src/runtime/api/bun/h2_frame_parser.rs (three object-walk loops in send_trailers, push_promise, and request, plus an empty-trailers fallback), src/js/node/http2.ts (five JS-side pre-checks that must classify properties the same way the encoder does, and a rewrite of Http2ServerResponse#writeEarlyHints()), and seven new tests in test/js/node/http2/node-http2.test.js.

Security risks

None identified. The change relaxes a rejection to a skip, which is what Node does; no untrusted input reaches new sinks. The writeEarlyHints() rewrite actually tightens validation in some cases (a :status hint now throws ERR_HTTP2_PSEUDOHEADER_NOT_ALLOWED instead of being sent). The narrowed is_null() checks keep null rejected exactly as before.

Level of scrutiny

Moderate-to-high. HTTP/2 HPACK encoding is a protocol-correctness-critical path — the PR description itself notes that a half-encoded block desyncs the connection's HPACK table. The individual edits are small and mechanical (hoist iter.value, add an is_undefined() guard, add headers[name] === undefined continues), but they span three native loops and five JS pre-checks that must all agree. The writeEarlyHints() rewrite is the least mechanical piece: it changes behavior in both directions (new rejections for pseudo-headers and post-headersSent bad hints; new acceptances for whitespace-padded names and Link alongside link), all documented as matching Node v26.3.0 in the second behavior matrix.

Other factors

The PR description is unusually thorough (50-row and 7-row behavior matrices against Node v26.3.0), the seven new tests each assert on wire-decoded field lists with error paths wired to reject, and CI passed on all jobs that ran. The comment-cop bot flags are automated length heuristics; the author already shortened comments in two follow-up commits and the remaining flagged comments (3-4 lines each) explain genuinely non-obvious rationale. The encoded_headers.is_empty() fallback overlaps with open PR #37951 (noted by robobun). Given the breadth of the protocol surface touched and the writeEarlyHints() behavior changes, deferring to a human maintainer rather than auto-approving.

@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.

I reviewed this PR and didn't find any bugs. Because it touches HTTP/2 header encoding across both the JS pre-checks and three native HPACK object walks, plus a semantic rewrite of writeEarlyHints() that adds new throw sites, a human look would still be worthwhile.

What was reviewed:

  • The three native iter.value.is_undefined() hoists in request()/push_promise()/send_trailers() — iter.value is set by iter.next() before the loop body, so reading it early is safe; null handling is narrowed to is_null() and unchanged.
  • The empty-trailers fallback in send_trailers() matches no_trailers() line-for-line (wait_for_trailers = false + empty DATA with END_STREAM).
  • writeEarlyHints() now delegates to writeInformation(), which preserves the removed headersSent || closed guard; the candidate concern about assertSingleValueHeaders running before pseudo-header defaulting was ruled out.
Extended reasoning...

Overview

This PR makes Bun's node:http2 skip properties whose value is undefined in outbound header blocks, matching Node's buildNgHeaderString behavior. It touches three files: five JS pre-checks in src/js/node/http2.ts (assertSingleValueHeaders, assertNoConnectionHeaders, the request() name loop, the additionalHeaders() pseudo-header loop, the no-payload content-length check), a rewrite of Http2ServerResponse#writeEarlyHints() to validate hints via assertValidHeader and delegate to writeInformation(), and three native object walks in src/runtime/api/bun/h2_frame_parser.rs (request(), push_promise(), send_trailers()) plus an empty-block fallback in send_trailers(). Seven new tests (~380 lines) cover every builder and the compat boundary.

Security risks

None identified. The change relaxes rejection (previously-throwing calls now succeed by omitting the field), which cannot introduce header injection since the field is not sent at all. The one path that gains rejections, writeEarlyHints(), adds Node's own assertValidHeader + checkIsHttpToken validation. HPACK state consistency is improved (the old code could desync the encoder table by throwing mid-block).

Level of scrutiny

High. This is protocol-level HTTP/2 frame encoding across two languages with HPACK state implications, and writeEarlyHints() gains new throw sites (:status hint, bad hint after writeHead). The PR is exceptionally well-documented (50-row node/main/PR behavior matrix, node v26.3.0 verification) and thoroughly tested, but it is not a mechanical change.

Other factors

CI (build 93693) passed on all agents that ran; the two macOS 26 jobs were cancelled by infrastructure. The comment-cop bot flagged long comments on an earlier revision; commits b9180b1 and 463da77 shortened them and the current diff has one-line comments. There is a known textual overlap with #37951 (same empty-trailers rule) and #37945 (assertSingleValueHeaders neighbouring lines), noted in the PR. The deliberate decision to leave null rejected (deviating from Node, which stringifies it) is called out and deferred to #37579 — that design boundary is worth a maintainer's eye.

node's buildNgHeaderString drops a property whose value is undefined before
validating its name or counting it towards a single-value field. The native
encoders behind request()/respond()/additionalHeaders() and sendTrailers()
rejected such a property with ERR_HTTP2_INVALID_HEADER_VALUE (asynchronously
for a client request, leaving the HPACK table out of step with the peer), and
pushPromise() skipped it only after validating its name. All three now skip
it first, and the JS checks that run before encoding (single-value rule,
request() name validation, the pushStream() connection-header check and the
no-payload content-length check) ignore it as well.

A trailer block in which every field was skipped now ends the stream with an
empty DATA frame, the frame noTrailers() and node send for an empty trailer
list, instead of a zero-length HEADERS block.

Http2ServerResponse#writeEarlyHints() was relying on the native rejection;
it now validates each hint the way node's compat layer does (assertValidHeader
plus a token check on the trimmed, lowercased name) and goes through
writeInformation().

null values are left as they were on every path.
…ts rejections

The sendTrailers() comment claimed that nghttp2 peers reject a zero-length
trailer block; node's client and curl both accept one. The empty DATA frame
is sent because that is what node sends, so say that instead.

The trailers test now includes a pseudo-header name with an undefined value,
the one block where the value has to be looked at before the name, and the
compat test pins the rejections writeEarlyHints() gains from node's
validation order (a :status hint, a bad hint with an empty link list) and
the shape of the blocks it hands down.
… the request(), pushStream() and sendTrailers() tests
@robobun
robobun force-pushed the farm/fe371d5b/http2-skip-undefined-header-values branch from 171a4a3 to e942735 Compare August 13, 2026 10:29

@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.

I reviewed this PR and didn't find any bugs. Because it changes header-block handling across three native encoder loops, six JS pre-check sites, and refactors writeEarlyHints() (which both gains and loses rejections), plus carries an inline copy of #37951's empty-trailers fallback that needs a merge-order decision, a human look would still be worthwhile.

Checked: the is_undefined() skip is placed before name validation and single-value accounting in all three native loops, matching node's buildNgHeaderString order; the narrowed is_null() checks preserve the existing null rejection; the new encoded_headers.is_empty() fallback in send_trailers() matches no_trailers() line-for-line (wait_for_trailers = false + empty DATA with END_STREAM); the now-unconditional scalar branch in request() is safe because undefined continues at the top and null throws before reaching it.

Extended reasoning...

Overview

This PR makes Bun's node:http2 skip object properties whose value is undefined when building outbound header blocks, matching Node's buildNgHeaderString. It touches three files: src/runtime/api/bun/h2_frame_parser.rs (three encoder loops in send_trailers(), push_promise(), and request() — each gains a top-of-loop is_undefined() skip and narrows the later check to is_null(); send_trailers() also gains an empty-block fallback), src/js/node/http2.ts (six JS-level pre-checks skip undefined-valued keys; writeEarlyHints() is refactored to validate each hint via assertValidHeader() + checkIsHttpToken() and route through writeInformation()), and ~380 lines of new tests in test/js/node/http2/node-http2.test.js covering each block builder plus the compat boundary.

Security risks

None identified. The change loosens validation only by silently dropping undefined-valued properties (which are never sent, so no injection surface). null is deliberately unchanged on every path. The writeEarlyHints() refactor actually adds validation (pseudo-header rejection, token check on trimmed/lowercased names) to match Node's compat layer.

Level of scrutiny

Moderate-to-high. This is HTTP/2 protocol handling — the PR itself notes that the pre-fix bug could desync the HPACK encoder table and corrupt every subsequent request on the session. The change spans native Rust and built-in JS, and the writeEarlyHints() refactor changes behavior in both directions (6 of 7 rows in the compat matrix change: some inputs newly throw, others newly succeed). While the work is exceptionally well-documented (behavior matrices verified against Node v26.3.0) and thoroughly tested, the surface area — three native encoder loops, six JS pre-checks, one compat-layer refactor, one new native fallback path — is beyond what I'd approve without human review.

Other factors

CI passed on the first push (build 93693, 179/181 jobs; the two skipped were infra-cancelled macOS 26). All comment-cop feedback about long comments was addressed in follow-up commits. There is a stated overlap with #37951 (the standalone empty-trailers fix) that carries the same encoded_headers.is_empty() hunk and needs a merge-order decision from a maintainer. The tests are strong: they assert on wire-level fields decoded by the peer, wire error events on both sessions to fail the test, and cover the defined-then-undefined ordering that distinguishes a skip placed before vs after single-value accounting.

node-http2.test.js has a dozen pre-existing tests that run at 3-5s on a
debug build, so the whole file cannot be run reliably under ASAN on a busy
machine; the tests for this change are self-contained and the other open
header PRs all insert at the same spot in that file, so they get their own
file next to it. The pushStream() case now collects the pushed blocks
through the shared session helper instead of the big file's helper.

@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.

I reviewed this PR and didn't find any bugs. Because it changes HTTP/2 header encoding across both the native encoder (h2_frame_parser.rs) and the JS pre-checks, rewrites writeEarlyHints() with validation that both adds and removes rejections, and changes wire behavior for empty trailer blocks, a human look would still be worthwhile.

Checked: the removed is_empty_or_undefined_or_null() guards are safe since JSPropertyIterator::next() already skips JSEmpty internally (JSPropertyIterator.rs:207); the encoded_headers.is_empty() fallback matches no_trailers() exactly; writeInformation() carries the same headersSent || closed guard the old inline path had; the JS-side skips (assertSingleValueHeaders, assertNoConnectionHeaders, the request()/additionalHeaders() name loops, the content-length check) each align with the encoder's new skip position.

Extended reasoning...

Overview

This PR makes bun's node:http2 skip properties with undefined values in outbound header blocks, matching Node's buildNgHeaderString (if (value === undefined || key === '') continue;). It touches three layers:

  • Native (src/runtime/api/bun/h2_frame_parser.rs): three object-walk loops (send_trailers, push_promise, request) now hoist iter.value and skip on is_undefined() before name validation and single-value accounting; the residual value checks narrow from is_undefined_or_null() / is_empty_or_undefined_or_null() to is_null(). send_trailers gains an encoded_headers.is_empty() fallback that ends the stream via an empty DATA+END_STREAM frame (mirroring no_trailers() and Node's SubmitTrailers).
  • JS built-in (src/js/node/http2.ts): five pre-encoder checks (assertSingleValueHeaders, assertNoConnectionHeaders, the request() name-validation loop, the additionalHeaders() pseudo-header loop, the content-length-on-no-payload check) each add a === undefined skip so they agree with the encoder. writeEarlyHints() is rewritten to validate each hint via assertValidHeader() + checkIsHttpToken() (like setHeader()) and then delegate to writeInformation().
  • Tests: a new 403-line file with seven end-to-end tests, one per block builder plus the empty-trailers case and the compat boundary, asserting on the field list the peer decoded and pinning the expected values against Node v26.3.0.

Security risks

None identified. The change loosens validation only for undefined values (which are dropped, not sent), and the writeEarlyHints() rewrite adds Node's hint validation before anything reaches additionalHeaders(). No untrusted-input parsing paths are widened.

Level of scrutiny

High. This is production Node compat code in the HTTP/2 stack: the native side is the HPACK encoder where a mid-block throw already caused an HPACK-table desync (called out in the PR), and writeEarlyHints() is a user-facing API whose rewrite both adds rejections (:status hint now throws ERR_HTTP2_PSEUDOHEADER_NOT_ALLOWED; bad hints throw even when link is empty or headers were already sent) and removes some (whitespace-padded names now trimmed; a Link hint is no longer dropped). The empty-trailers change alters what goes on the wire. This is not a mechanical change.

Other factors

  • The PR description includes a 50-row behavior matrix run against Node v26.3.0; every changed row moves to Node's result and null is deliberately unchanged on every path.
  • The removed is_empty guard on the scalar branch in request() and the narrowed check in push_promise() are sound because JSPropertyIterator::next() itself does if current.is_empty() { continue; }, so iter.value is never JSEmpty when a name is yielded.
  • The encoded_headers.is_empty() fallback is byte-for-byte what no_trailers() does (wait_for_trailers = false; send_data(b"", true, UNDEFINED, false, false)).
  • writeInformation() already checks stream.headersSent || this[kState].closed, so delegating to it preserves that guard; the validation loop now runs before the empty-link early return, which is the intended Node behavior per the second matrix.
  • All comment-cop inline comments are resolved; the PR reports the full test/js/node/http2/ suite and all 256 ported test-http2-*.js files pass.
  • There is an acknowledged overlap with open PR #37951 (the standalone empty-trailers fix at the same site); whichever lands second drops its copy. That coordination is a merge-order decision a maintainer should make.

Given the multi-layer scope, the user-facing semantic changes to writeEarlyHints(), the wire-format change for trailers, and the interaction with #37951, I'm deferring rather than approving.

robobun added a commit that referenced this pull request Sep 6, 2026
… bound like nghttp2

Also complete a pending close() when the last request throws during
submission, and drop the undefined-value test rows that #37953 changes.
@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: superseded by #41614, which skips undefined values in all three native header walks on top of the #41520 encode-after-validation change. This branch no longer merges.

@robobun robobun closed this Sep 6, 2026
robobun added a commit that referenced this pull request Sep 6, 2026
… bound like nghttp2

Also complete a pending close() when the last request throws during
submission, and drop the undefined-value test rows that #37953 changes.
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