Skip to content

node:http2: refuse a header block over the default send limit, like node - #43474

Open
robobun wants to merge 4 commits into
robobun/8236ee7f/http2-respond-header-block-lengthfrom
robobun/4e2adf3a/http2-oversized-header-field
Open

robobun wants to merge 4 commits into
robobun/8236ee7f/http2-respond-header-block-lengthfrom
robobun/4e2adf3a/http2-oversized-header-field

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A node:http2 server responds with one header field over 64 KiB. The wire shows DATA with END_STREAM and no HEADERS, then GOAWAY code 9. Node sends RST_STREAM FRAME_SIZE_ERROR and GOAWAY NO_ERROR.
  • The HPACK encoder fails for a field over 65536 bytes. With maxSendHeaderBlockLength unset, the native request() (src/runtime/api/bun/h2_frame_parser.rs) calls schedule_header_compression_session_error(). The stream stays open, so stream.end() writes DATA.
  • nghttp2 refuses a block over maxSendHeaderBlockLength (default 65536) before it encodes it. Bun has no default.

Fix

  • request() now collects every block in HeaderList (from node:http2: reset the stream when respond() exceeds maxSendHeaderBlockLength #43440) before it encodes it. An unset option means 65536. A block over the limit takes node:http2: reset the stream when respond() exceeds maxSendHeaderBlockLength #43440's refusal path: frameError, then RST_STREAM FRAME_SIZE_ERROR on a server or a local REFUSED_STREAM on a client.
  • Behavior change: a block over 64 KiB in total is now refused by default, as in node. maxSendHeaderBlockLength raises the limit.
  • Correct because the refused block never reaches the encoder, so the other streams finish. As in node, respond() now throws for a 1xx status, and a refused additionalHeaders() block leaves the stream open for respond().
  • Verified: test/js/node/http2/h2-conformance.test.ts (twelve new tests: eleven fail on main, all twelve pass on node v26.3.0), test/js/node/http2/, node's test-http2-*.js. Self-reviewed: 3 concerns raised, 2 addressed.

Background

  • Stacked on node:http2: reset the stream when respond() exceeds maxSendHeaderBlockLength #43440. It adds the check for a user-set limit and the graceful session close after frameError.
  • The native request() serves respond(), additionalHeaders() and the client request().
  • HPACK encoder state is per connection. A block that is encoded but not sent desyncs the peer's decoder.
  • Cost: one copy of the header bytes per block.
Notes

Measured with a raw frame client, node v26.3.0 as the reference. The big field is 70000 or 200000 bytes, the option is unset.

case node v26.3.0 main (367d939) this PR
respond() + end("body") with the field RST_STREAM 6, GOAWAY 0, frameError 1,6, stream error NGHTTP2_FRAME_SIZE_ERROR DATA "body" END_STREAM with no HEADERS, GOAWAY 9, ERR_HTTP2_SESSION_ERROR same as node
array value, raw header list, compat res.setHeader() + res.end() same as the row above same as the row above same as node
70 fields of 1000 bytes same as the first row the block is sent same as node
additionalHeaders() with the field (103, or no :status), then respond(200) + end("body") frameError, then HEADERS 200 and DATA, GOAWAY 0, rstCode 0 HEADERS 200, DATA, GOAWAY 9 same as node
a second stream in flight it completes, GOAWAY 0 the session ends with code 9 and the second stream fails it completes, GOAWAY 0
client request() with the field frameError 1,6, ERR_HTTP2_STREAM_ERROR NGHTTP2_REFUSED_STREAM, then node closes the session ERR_HTTP2_SESSION_ERROR code 9. The peer also sees an empty DATA frame on a stream with no HEADERS the same request error as node. The session stays open, as #43440 does for a user-set limit

The default limit. The first two versions of this PR refused only a block that holds a single field over 65536 bytes, to keep every block that bun sends today. A review asked for node's behavior. over_send_limit() now uses nghttp2's default (nghttp2_frame.h#L58) with the bound from #43440: nghttp2_hd_deflate_bound() (12, plus 12 per field, plus the name and value bytes) plus the 5 priority bytes of HEADERS. Flip points, found by bisection, are the same on node v26.3.0 and on this branch:

largest value sent smallest value refused
respond({ ":status": 200, "x-big": v }) (with the default date field) 65435 65436
client.request({ ":path": "/", "x-big": v }) (5-digit port in :authority) 65402 65403
respond() with maxSendHeaderBlockLength: 400 299 300

With the option unset the encoder can no longer fail on size, because a block under the limit holds no field over 65536 bytes.

The tests hold node's expectations only. I ran the describe block on node v26.3.0: the test file transpiled with bun build --no-bundle, and bun:test / harness replaced by a 50-line stand-in (describe, test, test.each, expect().toEqual() on assert.deepStrictEqual). 12 pass. The version before this change failed two tests there: the 65536-byte field (bun sent it, node refuses it) and the refused additionalHeaders() block without :status (bun reset the stream, node sends the response).

respond() with a 1xx status, and additionalHeaders(). A refused additionalHeaders() block is not the response, so the stream stays open and respond() + end() from the same tick still go out. If nothing responds in that tick, the frameError handler from #43440 resets the stream on setImmediate, as node's onFrameError does. #43440 told a 1xx block by its :status value. That was wrong in two cases. Bun's respond() accepted 100 to 199, so respond({ ":status": 103, <the field> }) kept the stream open and end("body") wrote DATA with no HEADERS. Node's validatePreparedResponseHeaders() throws ERR_HTTP2_STATUS_INVALID for a status below 200 (core.js#L2670-L2678), and respond() now does the same. And bun's additionalHeaders() adds :status: 200 to a block without one, so that block reset the stream. additionalHeaders() now passes a flag to the native request(), and HeaderList::is_informational() is gone. An earlier commit of this PR added a closed-stream check to request() for the second case. It is removed, because no path reaches it now.

HPACK state after a refusal. The test a stream in flight completes and its headers still decode sends the same small field in the refused block and in the response of the second stream. A real client decodes the second response. If the refused block had reached the encoder, the second block would carry an index that the client never received. I also checked the client direction against a node server (strict decoder): the request after a refused request decodes there.

A side effect of the staging. The encoder now runs after the walk, after the options are parsed and after the session memory check. A call that throws during the walk (an invalid header value after a valid field), or returns early after it (an invalid weight, an aborted signal, ENHANCE_YOUR_CALM), no longer leaves a block in the encoder that the peer never receives. Measured for the throw, with a node v26.3.0 server as the peer: client.request({ ":path": "/one", "x-a": "v", "x-bad": "line\nbreak" }) throws ERR_HTTP2_INVALID_HEADER_VALUE on both builds. On main the next request that carries x-a makes the node peer report Protocol error, and the session ends with code 9. With this PR the next two requests get 200 (3 of 3 runs).

Overlap with #41520. That PR fixes the throw case for request(), pushStream() and sendTrailers() with its own HeaderList, and it also adds the pre-compression check for a user-set limit. With the option unset, an encoder failure there still reports the session error and leaves the stream open (read from its diff at 90f334a, not run). #41520 and the #43440 stack both rewrite the walk in the native request(), so they conflict in source. The one that lands second needs a rebase.

Where the session error stays. Server or client with maxSendHeaderBlockLength: 100000 and a 90000-byte field. Node sends that block (HEADERS plus four CONTINUATION frames). Bun cannot, because the encoder limit is per field. The block is under the user's limit, so the staged encode loop fails and the session error from #34432 stays. fails the whole session when an outbound header block cannot be encoded, delivers a session error from the event loop, not inside the call that detected it and delivers the reserved push stream and fails the session when its headers cannot be encoded still pass without a change.

One existing test changes. headers cannot be bigger than 65536 bytes in node-http2.test.js pinned ERR_HTTP2_SESSION_ERROR code 9 for a client with the default limit. Node v26.3.0 gives ERR_HTTP2_STREAM_ERROR NGHTTP2_REFUSED_STREAM for that request (measured with and without TLS). The test now expects that.

Self-review. Three concerns. Two are addressed: respond() with a 1xx status, and the additionalHeaders() block without :status. The third is the raised limit case above. It is not addressed: three tests pin that session error, and the connection ends in both outcomes.

Not in this PR

History. The first version called reject_oversized_header_block() from an older #43440 head. #43440 then moved to HeaderList staging and a graceful close, and removed that helper, so the second version staged every block and refused a single field over 65536 bytes. The third version (e7d15ae) uses nghttp2's default for the whole block, after review. The branch is not rebased any more, because #43619 and #43632 sit on it.

Suites run with the debug (ASan) build at e7d15ae: test/js/node/http2/ (600 pass, 6 skip, 0 fail), 261 test/js/node/test/{parallel,sequential}/test-http2-*.js files (all exit 0), test/js/bun/http/serve-http2.test.ts (93 pass), test/js/web/fetch/fetch-http2-client.test.ts (75 pass), test/regression/issue/{25589,24924,26915,29073}.test.ts (27 pass), test/js/third_party/grpc-js/ (318 pass, 10 fail: five DNS tests and the tonic test fail with the release build in this container too, and the four test-outlier-detection cases are 5 s timeouts under load that also occur with a debug build of the #43440 head).


[human-review] gate passed · iteration 6 · 4 files touched

fails on main (without fix)
ASAN without fix: 16 failed, 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/h2-conformance.test.ts" "test/js/node/http2/node-http2.test.js"
bun test v1.4.3 (367d939d9)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [806.75ms]
(pass) node none > Client Basics > should be able to send a POST request [532.79ms]
(pass) node none > Client Basics > constants [16.97ms]
(pass) node none > Client Basics > getDefaultSettings [6.94ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [16.51ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.39ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.21ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [4.92ms]
(pass) node none > Client Basics > should be able to send data using end [555.64ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [545.32ms]
(pass) node none > Client Basics > http2 sh
... (truncated)

release without fix: 6 skipped
bun test v1.4.3-canary.1 (e7d15ae8b)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [0.88ms]
(pass) node none > Client Basics > getDefaultSettings [0.17ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.29ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.19ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [0.05ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [0.08ms]
(pass) node none > Client Basics > is possible to abort request [2.11ms]
(pass) node none > Client Basics > aborted event should work with abortController [0.95ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [0.84ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [0.87ms]
(pass) node none > Client Basics > should fail to connect over HTTP/1.1 [35.20ms]
(skip) node none > Client Basics > should not leak memory
(pass) node none > Client Basics > headers cannot be bigge
... (truncated)
passes on PR (with fix)
ASAN with fix: 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/h2-conformance.test.ts" "test/js/node/http2/node-http2.test.js"
bun test v1.4.3 (367d939d9)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [917.97ms]
(pass) node none > Client Basics > should be able to send a POST request [552.47ms]
(pass) node none > Client Basics > constants [17.40ms]
(pass) node none > Client Basics > getDefaultSettings [7.64ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [17.09ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.23ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [2.91ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [4.70ms]
(pass) node none > Client Basics > should be able to send data using end [571.07ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [555.74ms]
(pass) node none > Client Basics > http2 sh
... (truncated)

release with fix: 6 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 750ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/126] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 245 extern-C blocks audited
[2/126] gen JS modules (bundle-modules)
Preprocess modules (7038ms)
Bundle modules (52ms)
Postprocesss modules (22ms)
Bundle Functions (451ms)
Generate Code (31ms)

[7.60s] Bundled "src/js" for production
  2607 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[2/8] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  �[1m�[94m--> �[0msrc/install/windows-shim/Cargo.toml:41:8
 
... (truncated)
diff hotspot
src/js/node/http2.ts                      |   7 +-
 src/runtime/api/bun/h2_frame_parser.rs    | 148 +++++--------------
 test/js/node/http2/h2-conformance.test.ts | 234 ++++++++++++++++++++++++++++++
 test/js/node/http2/node-http2.test.js     |   8 +-
 4 files changed, 279 insertions(+), 118 deletions(-)

gate history · 2 passed · 1 rejected · iteration 6

evidence per changed file
file                                       reads  edits  tests
src/js/node/http2.ts                          20      6    109
src/runtime/api/bun/h2_frame_parser.rs        66     62    110
test/js/node/http2/h2-conformance.test.ts     16     12     89
test/js/node/http2/node-http2.test.js          3      2     30

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main (1.4.3-canary.1+367d939d9) and on the #43440 head with a raw frame client against a node:http2 server that calls stream.respond({ ":status": 200, "x-big": <200000 bytes> }) and stream.end("body"). Stream 1 gets DATA END_STREAM with no HEADERS frame, then GOAWAY 9. Node v26.3.0 sends RST_STREAM 6 and GOAWAY 0.

With this PR the wire matches node: RST_STREAM 6, GOAWAY 0, no DATA. With the option unset the limit is nghttp2's default of 65536 for the whole block, with the same flip point as node (a 65435-byte value in respond() is sent, 65436 is refused). A second stream in flight completes. A refused additionalHeaders() block leaves the stream open for the response.

bun bd test test/js/node/http2/h2-conformance.test.ts -t "over the send limit"

Eleven of the twelve tests fail on main. All twelve pass with this change, and all twelve pass on node v26.3.0 (run through a small stand-in for bun:test).

The branch is three commits (plus one empty CI commit) on the #43440 head (4566950). It is not rebased any more, because #43619 and #43632 are stacked on it. #43440 conflicts with main since #43649 (one hunk in http2.ts). When #43440 has main merged, I merge it into this branch.

This PR supersedes #36389 for this bug. That PR was closed in the stale PR sweep on 2026-09-13 and conflicts with main.

CI on e7d15ae (build 119478). 179 of 181 jobs passed. Every http2 test passed on every lane. The two red jobs do not come from this diff, and the rerun (build 119697, an empty commit) shows the same two:

  • binary-size flags bun-freebsd-x64 and bun-freebsd-aarch64 at about +3.5 MB. The FreeBSD binary of this build is 88.41 MB, the same as in the green build of the previous commit (118772). main's canary went from 88.40 MB to 84.92 MB after the merge base of this stack, so the check now compares against a smaller main. A new CI run gives the same result. The check passes again when the stack has main merged, which waits on node:http2: reset the stream when respond() exceeds maxSendHeaderBlockLength #43440.
  • test/bake/deinitialization.test.ts on alpine aarch64. The same test is in the flaky list of 7 of the last 12 main builds. It is reported separately.

@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 swap from the raw &mut *stream deref to enter_stream_dispatch at the three request() failure sites: reject_oversized_header_block runs onFrameError, end_stream and send_go_away synchronously with the &mut Stream live, but the GuardedStream arms the dispatch depth for the whole scope, so the pending-close drain that frees the map entry is deferred until the guard drops — no use-after-free through that path.

Extended reasoning...

Findings were reported inline (helper duplication, missing boundary tests, pushStream and client sibling sites, the name + value vs. block-size predicate), so this note only records what else was examined. The one memory-safety question a human would raise on this diff is whether holding a &mut Stream across three synchronous JS dispatches that tear the session down can free the stream; I read enter_stream_dispatch (h2_frame_parser.rs:2401) and reject_oversized_header_block (h2_frame_parser.rs:2192) and confirmed the DispatchGuard inside GuardedStream keeps the dispatch depth armed for the borrow's lifetime, which is the documented condition for deferring the only free path. The remaining findings still warrant a human look, so this is not an approval.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/runtime/api/bun/h2_frame_parser.rs — Servers calling pushStream() with a header over 64 KiB lose the entire connection: every in-flight stream dies with a session error, whereas Node only fails the pushed stream and the parent completes. The encode failure at src/runtime/api/bun/h2_frame_parser.rs:6162 still calls schedule_header_compression_session_error, the sibling server site the PR's new helper does not cover. The comment at 6159 ("Same as the request/respond encode failures") is now stale: respond no longer fails the session. Fix: route the pushStream failure through a per-stream rejection (frameError type PUSH_PROMISE on the parent, RST_STREAM INTERNAL_ERROR on the pushed stream) and update the comment.

    Extended reasoning...

    The base commit behaves the same, so the finder filed it as pre-existing. But this PR introduces the is_server distinction and a server-only helper, and leaves the only other server-side encode-failure site with a comment that now describes behavior the PR removed. Trigger: server handler on stream 1 calls stream.pushStream({':path':'/big', 'x-big': <70000 bytes>}, cb) while streams 3 and 5 are also being served on the same connection. push_stream at 6152 fails encode_header_into_list for x-big. 6162 schedules the session error. Next tick: session error COMPRESSION_ERROR, GOAWAY 9, streams 1, 3, 5 all destroyed with ERR_HTTP2_SESSION_ERROR. Node: frameError(5, ...) on stream 1, pushed stream closed INTERNAL_ERROR, streams 1, 3, 5 complete normally. Population: any server using push with a large header, per push. Remedy: reject the pushed stream only, following the shape the PR already added for respond, and remove the stale comment at 6159.

    Verification: pre-existing (acknowledged in diff: the PR description's "Not in this PR" list names pushStream() with an oversized field and correctly states the Node shape — frameError type 5 on the parent, pushed stream INTERNAL_ERROR, parent completes). Trigger: a server calls stream.pushStream() with a single header field whose name+value exceeds the default 65536 limit while other streams are live on…

Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread test/js/node/http2/h2-conformance.test.ts
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
@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:56 AM PT - Sep 22nd, 2026

❌ @robobun, your commit 71d879a has 2 failures in Build #119697 (All Failures):

  • test/bake/deinitialization.test.ts - code 1: (unnamed) — expect(received).toBe(expected) on 🐧 3.23 aarch64
  • 📦 Binary size — 2 over 0.50 MB
  • targetthis build canary: main #119654
    sizeΔ
    bun-darwin-aarch6460.61 MB60.64 MB-32.4 KB
    bun-darwin-x6466.34 MB66.39 MB-48.4 KB
    bun-linux-aarch6476.55 MB76.55 MB+0.0 KB
    bun-linux-x6476.97 MB77.03 MB-64.0 KB
    bun-linux-aarch64-musl69.51 MB69.57 MB-64.0 KB
    bun-linux-x64-musl70.95 MB71.00 MB-56.0 KB
    bun-linux-aarch64-android83.22 MB82.91 MB+310.6 KB
    bun-linux-x64-android86.28 MB86.20 MB+88.4 KB
    ❌ bun-freebsd-x6488.41 MB84.92 MB+3.49 MB
    ❌ bun-freebsd-aarch6489.79 MB85.95 MB+3.85 MB
    bun-windows-x6483.62 MB83.69 MB-70.5 KB
    bun-windows-aarch6472.68 MB72.51 MB+179.5 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 43474

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

bun-43474 --bun

@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 array-value site's return value changing from undefined to the stream id through the new helper — no caller in src/js/node/http2.ts reads request()'s return, so it is inert — and the helper's enter_stream_dispatch + reject_oversized_header_block shape, which matches the existing user-set-limit site at h2_frame_parser.rs:7108.

Extended reasoning...

This run's confirmed findings are posted inline, so approval is off the table. Since the last review the author added the 65536/65537 boundary cases and collapsed the three duplicated failure blocks into fail_unencodable_header_field, addressing two earlier threads; the client-side path and the name + value vs deflate_bound predicate remain as previously noted. Two additional candidates were investigated and ruled out: the changed return value at the array-value site (JSValue::UNDEFINED → stream id) has no consumer in http2.ts (request() results are discarded at lines 3498, 3631, 3633, 6246, 6248, 6313, 6315), and the helper's guarded-stream pattern mirrors the existing reject_oversized_header_block call site, so the switch from the raw &mut *stream deref is not a new lifetime concern.

Comment thread test/js/node/http2/h2-conformance.test.ts Outdated
Comment thread test/js/node/http2/h2-conformance.test.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.

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

@robobun
robobun force-pushed the robobun/4e2adf3a/http2-oversized-header-field branch from a45fea2 to 46a31fd Compare September 19, 2026 19:01

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

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

The base of this PR (#43440) changed shape at 1d6085f, so this branch needs a rebase.

  • reject_oversized_header_block() is gone. The reject path in node:http2: reset the stream when respond() exceeds maxSendHeaderBlockLength #43440 no longer ends the session.
  • request() now checks maxSendHeaderBlockLength before the HPACK encode, on nghttp2's pre-compression bound (HeaderList::deflate_bound() plus 5). On a server the refused block dispatches frameError, a refused final response is reset with FRAME_SIZE_ERROR, and the JS handler closes the session gracefully like node's onFrameError. The other streams on the session finish.
  • The fields are only staged when the option is set (staged is None otherwise).

The case here (one field over 64 KiB with the option unset) can use the same check: the field sizes are known before the encode, so the block can be refused before the encoder sees any of it. Then no session error and no early teardown are needed for that case either.

@robobun
robobun force-pushed the robobun/4e2adf3a/http2-oversized-header-field branch from 46a31fd to a05f627 Compare September 20, 2026 00:20
The HPACK encoder cannot emit a field whose name and value exceed 65536
bytes. With maxSendHeaderBlockLength unset, request() reported a session
error and left the stream open, so the response body went out as DATA
with no HEADERS frame.

request() now stages the fields of every block, also when the option is
unset. With the option unset it refuses a block that holds such a field
through the same path as a block over a user-set limit: frameError, then
RST_STREAM FRAME_SIZE_ERROR on a server or a local REFUSED_STREAM on a
client. nghttp2's default limit of 65536 refuses the same blocks. The
refused block never reaches the encoder, so the other streams of the
session keep a valid HPACK state.
@robobun
robobun force-pushed the robobun/4e2adf3a/http2-oversized-header-field branch from a05f627 to 8268eab Compare September 20, 2026 00:23

@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 always-staged refactor for lost behavior: the stream lookup and set_context at h2_frame_parser.rs:6897-6903 still run before both the new over_send_limit check and the encode loop, so the remaining schedule_header_compression_session_error fallback keeps the context the deleted inline blocks used to set. The assertion change in node-http2.test.js:584-587 (session error to NGHTTP2_REFUSED_STREAM) matches the client branch of the shared refusal path, which now covers a >64 KiB field with the option unset.

Extended reasoning...

Findings were reported inline, so this body only records what else was examined on the new push. The refactor removes three inline encoder-failure blocks and encode_or_stage_header, staging every field in a HeaderList unconditionally; I confirmed the stream context is set once at 6897-6903 before the send-limit check and the encode loop, so the retained encoder-failure fallback at 7141 is not missing the set_context the deleted blocks performed. The max_send_header_block_length == 0 arm uses has_field_over(65536) while the set arm keeps the deflate_bound() + priority > limit check, and both feed the pre-existing server/client refusal branch, which is consistent with the updated expectation in node-http2.test.js. The staged copy adds one extra copy of every header on the default path, a perf cost but not a correctness issue.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/runtime/api/bun/h2_frame_parser.rs — pre-existing: a server that calls respond() with a 1xx :status and a header field over 64 KiB still writes DATA on a stream that never got HEADERS, the wire violation this PR sets out to fix. h2_frame_parser.rs:7118 skips end_stream for every 1xx block, but respond() (http2.ts:3646) then sets headersSent and the user's end("body") passes can_send_data. Node resets the stream with FRAME_SIZE_ERROR for every refused HEADERS block, 1xx included. Fix: reset the stream on every refused block, or key the exemption on the additionalHeaders() entry point instead of the :status value, so no refused block can be followed by DATA.

    Extended reasoning...

    respond() in http2.ts:3500-3656 accepts any status 100-599 except 101 (:3594-3600), so stream.respond({":status": 103, "x-big": big}) reaches native request() with a 1xx block. Only the compat layer blocks 1xx (http2.ts:863); the core API does not.
    In request(), has_field_over(NGHTTP2_MAX_HEADERSLEN) is true at :7102, the server branch dispatches onFrameError at :7111, then :7118 sees is_informational() (HeaderList::is_informational at :2015 matches any :status whose value starts with '1') and skips end_stream. The stream stays OPEN and no HEADERS was encoded or written.
    Back in JS, respond() sets this.headersSent = true at :3646 and returns. The handler's stream.end("body") goes through _write (:2876) to native write_stream (:5932); can_send_data at :1861 returns true for OPEN, so send_data writes a DATA frame with END_STREAM on a stream the peer has never seen HEADERS for. nghttp2 clients treat DATA on a stream in opening state as a connection PROTOCOL_ERROR.
    On the base the same input produced a session error plus the same DATA-without-HEADERS frame, so this is the one path…

    Verification: pre-existing — the base already writes DATA with no HEADERS for this input by the encoder-failure route, and this PR fixes that wire violation for final responses but does not reach the 1xx-via-respond() case. Triggering condition: a server calls stream.respond({":status": 1xx, "x-big": <field whose name+value > 65536>}) with maxSendHeaderBlockLength unset, then writes/ends the stream.…

Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
…EADERS

A refused 1xx block keeps the stream open for the final response. Bun's
respond() accepted a 1xx status, so respond() with status 103 and a
field over 64 KiB still left DATA on the wire with no HEADERS. Node's
respond() throws ERR_HTTP2_STATUS_INVALID for a status below 200. Bun
now does the same, so only additionalHeaders() can send a 1xx block.

additionalHeaders() without ":status" sends a block that is not 1xx. A
refusal resets that stream, and a respond() in the same tick wrote
HEADERS after the RST_STREAM. The native request() now returns for a
closed stream, like no_trailers() and send_trailers().

The comment on NGHTTP2_MAX_HEADERSLEN now cites nghttp2 and names the
encoder constant it equals.
@robobun

robobun commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

The respond() with a 1xx status finding is fixed in 1efae9f, in a different place than the review suggested.

Node never sends a 1xx block from respond(). validatePreparedResponseHeaders() throws ERR_HTTP2_STATUS_INVALID for a status below 200 (core.js#L2670-L2678). I measured it on v26.3.0: stream.respond({ ":status": 103 }) throws, also with a small header block. Bun accepted 100 to 199 there, and with a small block it sent HEADERS 103 and then DATA with no final HEADERS. respond() now throws like node. So the 1xx exemption in the refusal path can only be reached from additionalHeaders(), which is the case node keeps open.

The same commit adds a closed-stream check to the native request(). additionalHeaders() without :status sends a block that is not 1xx (bun adds :status: 200). A refusal resets that stream, and a respond() in the same tick wrote HEADERS after the RST_STREAM. no_trailers() and send_trailers() already have that check from #43440.

Two new wire tests cover both cases. All eleven tests in the block pass. Ten fail on main, and nine fail with a debug build of the #43440 head.

@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 the two newest commits and didn't find any bugs; the earlier nits (boundary tests, asserted stream errors, the three duplicated failure blocks, the NGHTTP2_MAX_HEADERSLEN citation) are all addressed in the code now. Because this changes two user-visible contracts (a client request with an oversized field now fails with a stream error instead of a session error, and respond() now throws for 1xx), a human look would still be worthwhile.

What was reviewed:

  • Staging every header in HeaderList: push() copies name and value bytes, so slices of the reused stack name_buffer and per-iteration JS string slices are not held past the loop.
  • Unset-limit predicate: has_field_over(65536) uses the same > as the encoder's LSHPACK_MAX_HEADER_SIZE check in src/jsc/bindings/c-bindings.cpp:471, so with the option unset the staged encode loop cannot fail and the remaining session-error path is only reachable with a user-set limit above 65536.
  • The new StreamState::CLOSED early return in request(): base would have encoded and written HEADERS on a reset stream; the new return touches neither the encoder nor the wire.
  • respond() rejecting 1xx: compat writeContinue/writeEarlyHints already go through additionalHeaders(), and no existing test under test/js/node/http2 calls respond() with a 1xx status.
Extended reasoning...

Overview

The diff (4 files) reworks how the native request() in src/runtime/api/bun/h2_frame_parser.rs handles an outgoing header block with a field the HPACK encoder cannot emit. All three header-input shapes (single value, array value, raw list) now stage into a HeaderList unconditionally, the encode_or_stage_header helper and its three copied failure blocks are deleted, and a single over_send_limit match decides refusal: has_field_over(NGHTTP2_MAX_HEADERSLEN) when maxSendHeaderBlockLength is unset, the existing deflate_bound() + priority > limit when set. A new early return after enter_stream_dispatch makes request() a no-op on a stream already in StreamState::CLOSED. src/js/node/http2.ts tightens respond() to reject status codes below 200. Tests add a describe block in h2-conformance.test.ts with a raw-frame client and change one expectation in node-http2.test.js (which runs against both node and bun).

Security risks

No auth, crypto, or path handling is involved. The change reduces attack surface slightly: an oversized field no longer poisons the shared HPACK encoder state, and it no longer converts one bad response into a session-wide COMPRESSION_ERROR that kills every other stream on the connection. The staging copy is bounded by the same header-size limits that already applied. Allocation failures in HeaderList::push are routed to a thrown JS error rather than a panic.

Level of scrutiny

Moderate. The native code is a protocol-state change in a hot path, but the diff is mostly a simplification (net -111 lines in the Rust file) and the refusal path it routes to already existed from the stacked PR. I checked the points a refactor like this usually breaks: HeaderList::push copies bytes so the reused name_buffer slice is safe to stage; the unset-limit predicate uses the same strict > as the encoder limit in c-bindings.cpp:471, so the staged encode loop can only fail when a user-set limit exceeds 65536 (the PR explicitly keeps the session error there); the CLOSED early return skips set_context only on a stream that is already reset, where base would have written a HEADERS frame on a closed stream. The two user-visible contract changes (client oversized request now ERR_HTTP2_STREAM_ERROR/NGHTTP2_REFUSED_STREAM; respond() throws ERR_HTTP2_STATUS_INVALID for 1xx) are both aligned with node's observable behavior and the node-run test file pins the first, but they are deliberate behavior changes a maintainer should consciously accept, which is why I defer rather than approve.

Other factors

The bug hunt exited on a dry streak with no findings, and I did not run the debug build here (no existing build artifact; the PR states the http2 suite and node's test-http2-* files pass). All prior review threads were resolved by the author, but the current code shows each was actually addressed: boundary tests at 65536/65537 exist and the 65536 case names the divergence from node in its title, streamErrors and sessionErrors are asserted in the shared refused expectation, the three duplicated failure blocks are gone, and the new constant cites nghttp2_frame.h and names LSHPACK_MAX_HEADER_SIZE. One pre-existing detail outside this diff: deflate_bound() uses 12 bytes per field where nghttp2 uses 6; this only makes the user-set-limit check slightly more conservative and is unchanged by this PR.

@robobun

robobun commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks. For the maintainer who looks at the two contract changes, the evidence for each is in the Notes of the PR description:

  • Client request with a field over 64 KiB: node v26.3.0 fails that request with ERR_HTTP2_STREAM_ERROR / NGHTTP2_REFUSED_STREAM (measured with and without TLS). The changed assertion is headers cannot be bigger than 65536 bytes in node-http2.test.js.
  • respond() with a 1xx status: node throws ERR_HTTP2_STATUS_INVALID for a status below 200 (core.js#L2670-L2678).

One correction to the note on deflate_bound(): nghttp2 adds 6 * 2 * nvlen (nghttp2_hd.c#L1596), so 12 bytes per field in #43440 is the same bound, not a more conservative one.

@cirospaciari

Copy link
Copy Markdown
Member

@robobun Two new tests pin behaviour Node v26.3.0 does not have. A response field of exactly 65536 bytes is refused by nghttp2's deflate bound (deps/nghttp2/lib/nghttp2_session.c:2095) where Bun sends it, and after a refused additionalHeaders() block Node still sends the HEADERS 200 and DATA from respond()+end() in that tick (core.js:661-681 defers the close to setImmediate) where Bun sends only RST_STREAM. Unless these are for safety, please match Node so the tests pass on Node and Bun.
Repro: respond({':status':200,'x-big':'a'.repeat(65531)}); end() gives frameError 6 and no HEADERS on Node; additionalHeaders({'x-big':'a'.repeat(200000)}); respond({':status':200}); end('body') puts HEADERS 200 and DATA on the wire on Node.

…ter a refused additionalHeaders() block

With maxSendHeaderBlockLength unset, request() refused only a block
that held a field over 65536 bytes. nghttp2 uses 65536 as the limit in
that case and compares it with its bound for the whole block.
over_send_limit() now does the same. A block that node refuses is
refused, and a block that node sends is sent: the flip point for
respond() is a 65435-byte value on both.

A refused additionalHeaders() block is not the response. Node drops it
and still sends respond() and end() from the same tick. The native
request() took that from the :status value, and bun adds :status 200 to
a block that has none, so that block reset the stream.
additionalHeaders() now tells request() that its block is informational.

The closed-stream check in request() is removed. No path reaches it now.

The tests in the describe block hold node v26.3.0's expectations only.
@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

@cirospaciari Both now match Node in e7d15ae. Neither was for safety: the first was me keeping header blocks over 64 KiB that bun sends today, and the second came from a :status: 200 that bun's additionalHeaders() adds.

Default limit. With maxSendHeaderBlockLength unset, request() now uses nghttp2's default of 65536 and nghttp2's bound for the whole block (12 + 12 per field + name and value bytes, plus 5 priority bytes). Before, it refused only a single field over 65536 bytes. Measured against node v26.3.0 by bisection, the flip points are the same on both:

largest value sent smallest value refused
respond({ ":status": 200, "x-big": v }) 65435 65436
client.request({ ":path": "/", "x-big": v }) (5-digit port) 65402 65403
respond() with maxSendHeaderBlockLength: 400 299 300

Your repro ('a'.repeat(65531)) now gives frameError 6, RST_STREAM 6 and no HEADERS. This changes one thing for users: a block of many small fields over 64 KiB in total went out before and is now refused, as in Node. maxSendHeaderBlockLength raises the limit, as in Node.

Refused additionalHeaders() block. The native request() decided from the :status value whether a refused block is the response. additionalHeaders() without :status gets :status: 200 added in JS, so that block reset the stream. additionalHeaders() now passes a flag to request() instead. With your repro the wire is HEADERS, DATA "body" END_STREAM, GOAWAY 0, and the stream closes with rstCode 0, as on Node. The closed-stream check in request() from the previous commit is removed, because nothing reaches it now.

Tests. The describe block holds Node's expectations only. The 65536 test is now a pair at nghttp2's bound (exactly 65536 is sent, 65537 is refused), there is a new test for many small fields, and the additionalHeaders() test covers a 103 status and no :status. I ran the block on node v26.3.0 through a small stand-in for bun:test (the file transpiled with bun build --no-bundle): 12 pass. The previous version of the block fails exactly the two tests you named there. On bun: 12 pass with this branch, 11 fail on main.

Also run on the debug build: test/js/node/http2/ (600 pass, 0 fail), the 261 test-http2-*.js files from node's suite, serve-http2.test.ts, fetch-http2-client.test.ts, grpc-js.

@robobun robobun changed the title node:http2: reset the stream when a response header field is over the send limit node:http2: refuse a header block over the default send limit, like node Sep 22, 2026
@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

@cirospaciari CI on e7d15ae finished: 179 of 181 jobs passed, and the http2 tests passed on every lane. The two red jobs are not from this diff (details in the status comment above): binary-size compares against a main whose FreeBSD binary shrank by 3.5 MB after the merge base of this stack, and test/bake/deinitialization.test.ts is a known flaky test on main.

binary-size stays red until this stack has main merged. This PR is stacked on #43440, and #43440 conflicts with main since #43649 (one hunk in http2.ts) and has not moved since 2026-09-20. There are two ways forward, and I would like your call:

  1. Keep the stack. When node:http2: reset the stream when respond() exceeds maxSendHeaderBlockLength #43440 has main merged, I merge it into this branch.
  2. I merge main into this branch and change the base of this PR to main. The PR then also carries the commits of node:http2: reset the stream when respond() exceeds maxSendHeaderBlockLength #43440 (4 files in total), has a green binary-size, and can be reviewed and merged on its own. node:http2: reset the stream when respond() exceeds maxSendHeaderBlockLength #43440 would then be closed as included.

I do not rebase in either case, because #43619 and #43632 are stacked on this branch.

@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 the latest push (e7d15ae, the switch to nghttp2's default send limit) and found no bugs; since it turns a single-field guard into a default behavior change for every node:http2 response and client request over 64 KiB, a human look is still worthwhile.

What was reviewed:

  • The removed inline encoder-failure blocks in the three header loops: set_context now runs unconditionally at h2_frame_parser.rs:6900 before the deferred encode loop, and that loop still calls schedule_header_compression_session_error, so the old path's side effects survive for a user-set limit above 65536.
  • over_send_limit() against nghttp2: 12 + 12*n + name/value bytes matches nghttp2_hd_deflate_bound, and the 5 priority bytes are always added as in session_prep_frame; the 65536/65537 boundary test's 56 overhead is right for :status/200 + x-big with sendDate: false, which respond() honors.
  • The informational flag: only additionalHeaders() passes it, respond() now rejects 1xx like node's validatePreparedResponseHeaders, and the compat writeHead already throws ERR_HTTP2_INFO_STATUS_NOT_ALLOWED for 1xx.
  • pushStream()/sendTrailers() still encode inline and can hit the session-level COMPRESSION_ERROR for a >64 KiB field; the PR names this as excluded (overlap with #41520).
Extended reasoning...

The change touches src/runtime/api/bun/h2_frame_parser.rs (request() now stages every header field, checks the block against nghttp2's 65536 default via over_send_limit(), and drops the HeaderList::is_informational() heuristic and encode_or_stage_header() for a sixth boolean argument), src/js/node/http2.ts (additionalHeaders() passes that flag; respond() rejects status < 200), and two test files adding about 230 lines of raw-frame conformance tests plus one updated client-side expectation. It touches no auth, crypto, or injection surface; the sensitive part is HPACK encoder state on a shared connection, which the refusal-before-encode ordering protects. Deferring rather than approving because the latest commit changes default user-visible behavior (any response or request block over 64 KiB is now refused with RST_STREAM/REFUSED_STREAM instead of sent or session-failed), a maintainer (cirospaciari) commented on 2026-09-21 and the follow-up commit's text is not visible to me, and I could not run the debug build in this session to execute the new tests.

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.

3 participants