Skip to content

node:http2: fix stream id overflow in setNextStreamID and nextStreamID - #37542

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/5913d06c/http2-set-next-stream-id-overflow
Aug 12, 2026
Merged

node:http2: fix stream id overflow in setNextStreamID and nextStreamID#37542
Jarred-Sumner merged 1 commit into
mainfrom
farm/5913d06c/http2-set-next-stream-id-overflow

Conversation

@robobun

@robobun robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Repro

import http2 from "node:http2";
const server = http2.createServer((req, res) => res.end("ok"));
server.listen(0, "127.0.0.1", () => {
  const c = http2.connect(`http://127.0.0.1:${server.address().port}`);
  c.on("connect", () => {
    c.setNextStreamID(0.5);
    console.log("nextStreamID =", c.state.nextStreamID);
    const r = c.request({ ":path": "/" });
    r.on("response", h => console.log("response on stream", r.id, h[":status"]));
    r.resume();
    r.on("close", () => process.exit(0));
    r.end();
  });
});

Debug build:

panic: attempt to subtract with overflow
<bun_runtime::api::h2_frame_parser_body::H2FrameParser>::set_next_stream_id

Release builds wrap instead of panicking. Node (v26) prints nextStreamID = 1 and response on stream 1 200: the native call is a no-op there because nghttp2 rejects id 0.

Cause

setNextStreamID in http2.ts mirrors node's wrapper (validateNumber plus id <= 0 || id > 2 ** 32 - 1), so a fractional id in (0, 1) is accepted and arrives in H2FrameParser::set_next_stream_id as 0 after to_u32(). The setter stores the id to step from by subtracting 1 or 2, which underflows for 0 (and the client branch special-cased 1 for the same reason).

get_next_stream_id has the same problem in the other direction: the setter accepts the whole u32 range, and once a server session is parked at 2 ** 32 - 1, computing the next id (session.state, pushStream) adds past u32::MAX:

panic: attempt to add with overflow
<bun_runtime::api::h2_frame_parser_body::H2FrameParser>::get_next_stream_id

On release that wraps to 0, which state.nextStreamID then reports. Today this second site is only reachable through the native handle, because ServerHttp2Session does not expose setNextStreamID (node does; tracked separately), but it is the same arithmetic.

Fix

Both steps use saturating_sub / saturating_add (src/runtime/api/bun/h2_frame_parser.rs). An id with nothing to step back from lands on the initial state, so setNextStreamID(0.5) leaves a fresh client on stream 1 like node; a parked-out server reads back as an id above MAX_STREAM_ID, which getNextStream / request already reject as out of streams. The == 1 special case in the setter and the unreachable == 0 arm in the getter (0 is even) go away with it. No JS change: the wrapper already matches node's validation.

Verification

New test http2 setNextStreamID at the edges of the id space does not overflow in test/js/node/http2/node-http2.test.js spawns a fixture that sets 0.5 and 1 on a client, 0, 2 and 2 ** 32 - 1 on a server session, and reads state.nextStreamID after each. Without the fix it aborts with the panics above on a debug build, and on the release binary (USE_SYSTEM_BUN=1) it fails on the server values (0 where 2 and 4294967295 are expected). With the fix it passes, along with the rest of node-http2.test.js and the ported test-http2-client-setNextStreamID-errors.js, test-http2-no-more-streams.js, test-http2-client-destroy.js and test-http2-session-stream-state.js.


[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 failed, 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.0 (2c1bd68de)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [810.80ms]
(pass) node none > Client Basics > should be able to send a POST request [539.14ms]
(pass) node none > Client Basics > constants [18.15ms]
(pass) node none > Client Basics > getDefaultSettings [6.62ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [21.79ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.01ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.08ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [5.02ms]
(pass) node none > Client Basics > should be able to send data using end [567.86ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [559.13ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving 
... (truncated)

release without fix: 1 failed, 6 skipped
bun test v1.4.0-canary.1 (9008ae7ab)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [0.80ms]
(pass) node none > Client Basics > getDefaultSettings [0.16ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.36ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.09ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [0.04ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [0.06ms]
(pass) node none > Client Basics > is possible to abort request [1.66ms]
(pass) node none > Client Basics > aborted event should work with abortController [0.73ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [0.60ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [1.08ms]
(pass) node none > Client Basics > headers cannot be bigger than 65536 bytes [34.67ms]
(skip) node none > Client Basics > should not leak memory
(pass) node none > Client Basics > should fail to con
... (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/mechgate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.0 (2c1bd68de)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [833.57ms]
(pass) node none > Client Basics > should be able to send a POST request [551.82ms]
(pass) node none > Client Basics > constants [18.29ms]
(pass) node none > Client Basics > getDefaultSettings [6.84ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [21.99ms]
(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 [3.07ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [5.09ms]
(pass) node none > Client Basics > should be able to send data using end [579.63ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [568.95ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving 
... (truncated)

release with fix: 6 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     2c1bd68ded
  features     baseline

22 deps, 107 codegen, 1176 objects in 1152ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] install /workspace/bun
bun install v1.4.0-canary.1 (9008ae7ab)

Checked 107 installs across 153 packages (no changes) [4.00ms]
[2/1238] gen ErrorCode+*.h
[3/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (9008ae7ab)

Checked 1 install across 2 packages (no changes) [1.00ms]
[4/1238] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (9008ae7ab)

Checked 129 installs across 147 packages (no changes) [6.00ms]
[5/1238] gen bindgenv2
[6/1238] fetch tinycc
[tinycc] up to date
[7/1237] gen .bind.ts → GeneratedBindings.cpp
[8/1237] fetch zlib
[zlib] up to date
[9/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[10/1237] fetch libjpeg-turbo
[libjpeg-turbo] up to date

... (truncated)
diff hotspot
src/runtime/api/bun/h2_frame_parser.rs | 44 +++++++++++-------------
 test/js/node/http2/node-http2.test.js  | 61 ++++++++++++++++++++++++++++++++++
 2 files changed, 81 insertions(+), 24 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                    reads  edits  tests
src/runtime/api/bun/h2_frame_parser.rs      6      3      0
test/js/node/http2/node-http2.test.js       3      4      0

…reamID

Http2Session.setNextStreamID(0.5) passes the JS range check (id > 0) and
reaches the native setter as 0, where computing the id to step from
subtracted past zero: a panic on debug builds and a wrap to 0xFFFFFFFF on
release. The matching step in get_next_stream_id could overflow the other
way once a server session was parked at 2 ** 32 - 1.

Both steps now saturate. Ids that cannot be stepped back from land on the
initial state, and a parked-out id reads back above MAX_STREAM_ID, which
the stream-opening callers already reject as out of streams.
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on a debug build with setNextStreamID(0.5) on a client session (panic: attempt to subtract with overflow in set_next_stream_id) and with a server session parked at 2 ** 32 - 1 followed by reading session.state (panic: attempt to add with overflow in get_next_stream_id). Release builds wrap silently in both cases.

Fix and test are in this PR; waiting on CI.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:04 AM PT - Aug 11th, 2026

@robobun, your commit 2c1bd68dedc9e7d276a85075308d307e1d9857cb passed in Build #92346! 🎉


🧪   To try this PR locally:

bunx bun-pr 37542

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

bun-37542 --bun

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The HTTP/2 frame parser now uses saturating arithmetic for stream ID advancement and rollback. A regression test covers boundary values and verifies a follow-up request.

HTTP/2 stream ID handling

Layer / File(s) Summary
Saturating stream ID arithmetic
src/runtime/api/bun/h2_frame_parser.rs
get_next_stream_id and set_next_stream_id prevent stream ID overflow and underflow for client and server parity rules.
Boundary regression coverage
test/js/node/http2/node-http2.test.js
The test validates zero, fractional, maximum, and normal IDs, then confirms a request uses stream ID 1 and returns status 200.

Possibly related PRs

  • oven-sh/bun#34432: Changes the same HTTP/2 parser and test file, but addresses different HTTP/2 behavior.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 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 HTTP/2 stream ID overflow fix in both setters and getters.
Description check ✅ Passed The description explains the cause, fix, reproduction, and verification results, covering the template requirements despite different section headings.

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 — straightforward saturating-arithmetic fix for a debug-build panic / release-build wrap.

What was reviewed:

  • Traced each edge input (0.5→0, 1, 2, u32::MAX) through the new saturating_sub/saturating_add paths for both client and server parity — results match the test's expected values and Node's behavior.
  • Confirmed the removed == 0 getter arm was unreachable (0 is even, caught by is_multiple_of(2)) and the removed == 1 setter special case is subsumed by 1.saturating_sub(2) == 0.
  • Verified all get_next_stream_id() callers that open a stream (h2_frame_parser.rs:8339, :8772) already reject > MAX_STREAM_ID, so a saturated result is handled as out-of-streams rather than used.
Extended reasoning...

Overview

Two-function change in src/runtime/api/bun/h2_frame_parser.rs: get_next_stream_id and set_next_stream_id swap raw += / -= on a u32 for saturating_add / saturating_sub, and drop two branches made redundant by saturation (an unreachable stream_id == 0 in the client getter — 0 is even so the preceding is_multiple_of(2) arm already covers it — and a == 1 special case in the client setter that existed only to avoid 1 - 2). One new spawned-fixture test in test/js/node/http2/node-http2.test.js exercises both edges.

Security risks

None. This is integer arithmetic on a stream-id counter; the only user-reachable input is a number already range-checked in JS (id <= 0 || id > kMaxStreams). The fix closes a debug panic / release wrap; it does not widen accepted input or change any validation gate.

Level of scrutiny

Low-to-medium. The change is mechanical (four arithmetic ops → their saturating variants) and the removed branches are provably dead or subsumed. I traced every value the test asserts through the new code by hand and confirmed the callers of get_next_stream_id at :8339 and :8772 already reject anything above MAX_STREAM_ID (i32::MAX), so a saturated u32::MAX is handled as "no more streams" rather than being used on the wire. The state.nextStreamID read at :6817 just reports the value, which now stays a large unusable id instead of wrapping to 0.

Other factors

The test follows harness conventions: subprocess spawn with bunExe()/bunEnv, concurrent drain of stdout/stderr/exited, error events wired to process.exit(1), and stderr/parsed-stdout asserted before exitCode. It reaches the server-side setter via Symbol.for("::bunhttp2native::") because ServerHttp2Session doesn't yet expose setNextStreamID — the PR description notes this is tracked separately, and the symbol matches src/js/node/http2.ts:350. The PR description states the fix was verified against the existing node-http2.test.js suite and the four ported Node parallel tests that touch this API.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

#37553 is stacked on this branch: it makes the native setter ignore ids of the wrong parity, ids not above the current next id and ids above 2 ** 31 - 1 (the nghttp2 rules node ends up with). With the setter bounded, last_stream_id stays within MAX_STREAM_ID on every path, so that PR swaps the saturating step back to plain arithmetic and changes one expectation in the test added here (2 ** 32 - 1 on a server leaves the next id at 2, which is node's value). If this lands first, #37553 rebases down to its own commit; if #37553 lands first, this one is covered by it.

@Jarred-Sumner
Jarred-Sumner merged commit f59f705 into main Aug 12, 2026
54 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/5913d06c/http2-set-next-stream-id-overflow branch August 12, 2026 03:43
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.

2 participants