Skip to content

node:http2: reject a PUSH_PROMISE whose promised id does not exceed the previous one - #37563

Open
robobun wants to merge 8 commits into
mainfrom
farm/2f7b2acf/h2-peer-stream-id-high-water-mark
Open

robobun wants to merge 8 commits into
mainfrom
farm/2f7b2acf/h2-peer-stream-id-high-water-mark

Conversation

@robobun

@robobun robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • http2.connect() accepts a PUSH_PROMISE whose promised id is below an earlier one, or equal to one whose stream has closed. For promise 4 then 2, bun 1.4.3 delivers both streams. node v26.3.0 fails the session with ERR_HTTP2_ERROR.
  • Cause: Connection::handle_push_promise (src/runtime/api/bun/h2/connection.rs) only checks streams.contains_key(promised). A lower id was never in that map, and a closed stream has left it.

Fix

  • Connection keeps the highest peer-initiated stream id in last_peer_stream_id. handle_push_promise rejects a promised id at or below it with the existing GOAWAY(PROTOCOL_ERROR).
  • RFC 9113 section 5.1.1 requires each new stream id to exceed every id its initiator used. nghttp2 fails the same frames.
  • Verified: describe "promised stream ids" in test/js/node/http2/h2-conformance.test.ts. Two of four tests fail on bun 1.4.3. All four pass with the fix and on node v26.3.0.

Background

  • A server reserves even stream ids with PUSH_PROMISE and must number them upward. The engine drops a stream's map entry when the stream closes.
  • Considered a check against the existing last_stream_id. That mark also rises when a response arrives on the client's own stream. It then rejects a legal push below that id (last test).

Downsides

  • Behavior change: a server can reuse or lower a promised id. Its clients now get a session error, not the pushed stream. node does the same.
  • Cost: Connection grows from 448 to 456 bytes per session (size_of). Each received PUSH_PROMISE does two more integer comparisons and one store. Release binary size: not measured.
Notes
  • Relation to h2 server: decode and discard HEADERS on a closed client stream instead of resetting or re-opening it #37985 (server direction, open): it declares the same last_peer_stream_id field and raises it for request ids. The declarations match, so the two merge in either order. nghttp2 keeps the same mark as last_recv_stream_id.
  • GOAWAY code on the wire: bun writes PROTOCOL_ERROR (the RFC's connection error). node writes INTERNAL_ERROR, because onSessionInternalError calls session.destroy(err), and destroy with an Error always encodes INTERNAL_ERROR before nghttp2 sends its own GOAWAY. The tests therefore assert the session error ({ code: "ERR_HTTP2_ERROR", errno: -505, message: "Protocol error" }) and client.destroyed, not the GOAWAY code. That makes them pass on both runtimes.
  • GOAWAY Last-Stream-ID, measured with release bun 1.4.3: a client connection error after a response on its own stream 3 writes last_stream_id=3 (node: 0). With requests sent but no response yet it writes 0, and session.close() writes 0. local_connection_error writes last_stream_id, which handle_headers raises for a response on the client's own stream. A bun or node server that receives such a GOAWAY fails the session with ERR_HTTP2_ERROR. This PR does not change the GOAWAY payload. node:http2: name only peer streams in GOAWAY and never raise the id (RFC 9113 6.8) #37588 changes that shared connection-error path to write the peer-initiated mark.
  • Node run method: the describe block and its helpers (RawH2Server, requestHeaderBlock, frame constants) are copied verbatim into one .mts file. A small expect/test shim over node:assert runs them. Results per test are in the PR thread.
  • The two acceptance tests (a higher promise after a reset one, a promise numbered below the client's own streams) pass on unfixed bun too. They guard against an over-strict check and against pointing the check at last_stream_id.
  • Measurements: size_of::<Connection>() read from a compile error (const _: [(); 0] = [(); size_of::<Connection>()];) on main (448) and on this branch (456), x86_64 Linux. The release binary size delta needs two release builds and was not measured. The change is one condition and one store in one function, with no new allocation, syscall or host function.
  • Other suites: CI passes the full h2-conformance.test.ts (88 tests) and node-http2.test.js on every lane. On a local debug build the four tests pass. One RST_STREAM flood test from node:http2: rate-limit stream resets per connection (CVE-2023-44487 rapid reset, CVE-2025-8671 MadeYouReset) #36230 and nine "DATA payload survives its ArrayBuffer being detached" tests time out at the 5 s default there.
  • Review nit not applied yet: clientWithRawServer does not release its session and raw server when its own setup waitFor times out. The callers' finally is not registered until the helper returns. The fix defines close() before the wait and calls it in a catch before the rethrow. It is verified on node and bun, and held so that CI does not restart on a green head. It goes in with the next push.
  • Earlier revision: this PR also made the server ignore request HEADERS on a client stream id at or below the highest one already accepted. That half is superseded by h2 server: decode and discard HEADERS on a closed client stream instead of resetting or re-opening it #37985 and was removed at the maintainer's request.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview 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: Essentials

Run ID: a7073a93-e4e6-4dbb-a0df-a5c76eb4d5b1

📥 Commits

Reviewing files that changed from the base of the PR and between d7488a3 and 064097e.

📒 Files selected for processing (1)
  • test/js/node/http2/h2-conformance.test.ts

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


Walkthrough

The connection now tracks peer-initiated stream IDs independently from local stream IDs. PUSH_PROMISE validation enforces increasing promised IDs, including after stream resets. Conformance tests cover protocol errors, valid higher IDs, and independent client stream IDs.

Changes

HTTP/2 promised stream validation

Layer / File(s) Summary
Peer stream high-water mark
src/runtime/api/bun/h2/connection.rs
Connection stores last_peer_stream_id, initializes it to zero, rejects non-increasing promised IDs, and updates the value for accepted promises.
Promised stream conformance tests
test/js/node/http2/h2-conformance.test.ts
The tests cover rejected reused IDs, valid higher IDs after reset, connection-level PROTOCOL_ERROR, and promised IDs below client-created stream IDs.

Suggested reviewers: cirospaciari, jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 06409

The client now rejects reused or non-increasing promised stream IDs with a protocol error, preventing invalid stream reuse. Current test results show no remaining merge-blocking risk.

🚥 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: rejecting PUSH_PROMISE frames with non-increasing promised stream IDs.
Description check ✅ Passed The description explains the problem, root cause, fix, behavior change, verification results, and test coverage. It does not use the exact template headings, but it provides the required content and r…

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

@robobun

robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on bun 1.4.3 with a raw server against http2.connect(): PUSH_PROMISE 4 then 2 (or 2, RST_STREAM, 2 again) delivers both pushed streams, where node v26.3.0 fails the session with ERR_HTTP2_ERROR.

Fix: handle_push_promise in src/runtime/api/bun/h2/connection.rs, with tests in test/js/node/http2/h2-conformance.test.ts that also pass on node v26.3.0. Review comments are addressed and CI is green.

@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 the h2 engine's stream state machine on untrusted network input (and closes a path the description notes could make stream ids repeat under the embedder's free in rewrite_read), a human look would still be worthwhile.

What was reviewed:

  • handle_headers: the new ignored gate is checked before refused, so can_open_stream() is not consulted for old ids; last_peer_stream_id is only written when !ignored, so it never regresses.
  • finish_header_block: stream_ignored returns after the HPACK decode loop and the fatal check but before any RST/sink callback, so the dynamic table stays in sync and nothing is surfaced or sent — the CONTINUATION test covers this.
  • handle_push_promise: promised <= last_peer_stream_id subsumes the old contains_key check across eviction; last_peer_stream_id is client-only here (server never receives PUSH_PROMISE), so it cannot collide with the server-side HEADERS watermark.
Extended reasoning...

Overview

The PR adds a per-peer stream-id high-water mark (last_peer_stream_id) to Connection in src/runtime/api/bun/h2/connection.rs and uses it in two places: (1) on a server, an inbound HEADERS whose id has no entry and is at or below the mark is decoded for HPACK sync and otherwise ignored (no stream, no callback, no wire response), matching nghttp2's NGHTTP2_ERR_IGN_HEADER_BLOCK; (2) on a client, a PUSH_PROMISE whose promised id is at or below the mark is a connection PROTOCOL_ERROR. A new header_stream_ignored flag threads the ignored case through the CONTINUATION-assembly path into finish_header_block. Six raw-socket tests are added to test/js/node/http2/h2-conformance.test.ts.

Security risks

This is the HTTP/2 frame parser processing untrusted network bytes. The change tightens behaviour (rejects/ignores previously-accepted frames) rather than loosens it, and the PR description points out that the pre-fix behaviour let a client make a stream id repeat under an embedder that frees keyed on id (rewrite_read), so the fix itself is security-positive. I checked that the new watermark is monotone (only assigned when hdr.stream_id > last_peer_stream_id is implied by !ignored), that ignored blocks still run through HPACK so §4.3 table sync is preserved, and that a fatal HPACK decode still tears the connection down before the stream_ignored early return.

Level of scrutiny

High. This is core protocol state-machine logic in a network-facing parser, and the chosen behaviour (silently ignore rather than GOAWAY) is a deliberate deviation from the RFC's literal text, justified by nghttp2 parity and the §5.1 late-trailer race. That reasoning is sound and well-documented in-source, but it is exactly the kind of non-obvious protocol judgement a maintainer should sign off on.

Other factors

Test coverage is thorough: lower-id after higher, replay on a completed id, HPACK dynamic-table sync across an ignored HEADERS+CONTINUATION, re-send on a refused id, a guard that the server's own even push ids do not raise the client-side watermark, and the client PUSH_PROMISE ordering case with wire-level GOAWAY and ERR_HTTP2_ERROR assertions. The tests reuse the file's existing RawH2/RawH2Server harness and follow its conventions (the { id, sync: undefined } vs { id } toEqual pattern already appears in exhaustedSession). No CODEOWNERS entry covers this path.

Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:29 AM PT - Oct 1st, 2026

✅ @robobun, your commit 875fbf94b68b7a9eb76545369b8a7f2363509919 passed in Build #122435! 🎉


🧪   To try this PR locally:

bunx bun-pr 37563

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

bun-37563 --bun

Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.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 changes the HTTP/2 engine's stream state machine on a network-facing path (and deliberately follows nghttp2's ignore behaviour rather than the RFC's literal connection error), a human look would still be worthwhile.

Checked: the ignored guard is server-only so client response HEADERS are unaffected; header_stream_ignored is threaded through CONTINUATION and reset alongside the other header_* flags; the early return in the decode path leaves header_end_stream/header_target set but they're overwritten by the next block (same as the existing refused path); the now-redundant contains_key guard in handle_push_promise is harmless.

Extended reasoning...

Overview

Adds a last_peer_stream_id high-water mark to the h2 Connection and a header_stream_ignored flag. On a server, an inbound HEADERS with no map entry and an id at or below the mark is decoded (for HPACK §4.3 sync, CONTINUATION included) and then dropped — no stream opened, nothing surfaced, nothing sent. On a client, a PUSH_PROMISE whose promised id does not exceed the mark is a connection PROTOCOL_ERROR. Nine raw-socket conformance tests are added, seven of which fail on the unfixed binary and two of which pin the per-peer semantics that distinguish the new mark from last_stream_id.

Security risks

The bug being fixed had a security angle: the embedder assumes stream ids never repeat within a session (the description points at a free in rewrite_read), and a hostile client could previously make them repeat by replaying a completed id. The fix closes that. The new behaviour is strictly more restrictive on the server and equally restrictive on the client (the contains_key check is subsumed by <= last_peer_stream_id). I did not find a way for the new code to loosen validation. The only user-controlled input flowing into the new state is the 31-bit stream id, already masked.

Level of scrutiny

High. This is a protocol state-machine change in production Rust on a network-facing path, with an intentional deviation from RFC 9113's literal wording (ignore vs GOAWAY) justified by nghttp2 precedent and the §5.1 late-frames tolerance rule. That is a design decision a maintainer should sign off on rather than a mechanical fix. The interaction between ignored, refused, stream_closed, HPACK decoding, CONTINUATION assembly, and last_stream_id advancement is subtle enough that it warrants a human pass over the state machine.

Other factors

The test coverage is thorough (both directions, HPACK sync across CONTINUATION, refused-then-replayed, late trailers, per-peer vs last_stream_id) and follows the file's existing raw-socket harness conventions. The comment-cop threads were all resolved after the author shortened the comments; the remaining two- and three-line comments record the nghttp2-vs-RFC decision, which is exactly the kind of non-obvious protocol choice that should be documented. No human review has landed yet.

@alii

alii commented Aug 13, 2026

Copy link
Copy Markdown
Member

Heads up: the server-side half of this (request HEADERS on a stream id the client already used → decode for HPACK, then drop) landed independently as #37985, rebased on top of #37637's Option<HeaderBlockInFlight> refactor. It adds the same last_peer_stream_id mark, and additionally makes the same-read case (entry still present in State::Closed) silent instead of RST_STREAM(STREAM_CLOSED), matching what node v22/v26 put on the wire in a raw-socket probe.

What #37985 does not cover is the client direction here — rejecting a PUSH_PROMISE whose promised id is at or below the previous one once the earlier promised stream has been evicted. If you rebase this down to just that handle_push_promise check + its test on top of current main, it should be a small, non-conflicting follow-up.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, will do: rebasing this onto current main and cutting it down to the PUSH_PROMISE check plus its tests.

…he previous one

RFC 9113 5.1.1 numbers every new promised stream above all earlier ones.
The client only caught a reused or lower id while the earlier promised
stream still had an engine entry; once that stream closed and was evicted
the promise was accepted and delivered again. Track the highest promised
id (the peer high-water mark, declared as in #37985 so the two merge
cleanly in either order) and fail the session like nghttp2 does.
@robobun
robobun force-pushed the farm/2f7b2acf/h2-peer-stream-id-high-water-mark branch from 64c2368 to 3a6fe2d Compare August 13, 2026 21:43
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs
@robobun robobun changed the title node:http2: ignore request HEADERS on a stream id the client already used (RFC 9113 5.1.1) node:http2: reject a PUSH_PROMISE whose promised id does not exceed the previous one Aug 13, 2026
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Done in 3a6fe2d: rebased onto main and cut down to the handle_push_promise check plus three tests (promise 4 then 2; promise 2, reset, promise 2 again; and a promised id 2 arriving after a response on the client's own stream 3, which is the case that needs the peer-only mark rather than last_stream_id). The last_peer_stream_id declaration and the advance in handle_push_promise are copied from #37985 byte for byte, so the two should merge cleanly in either order; once #37985 lands I will rebase again and this reduces to the one extra condition and the tests.

Two server-side scenarios from the earlier revision that I did not see in #37985's tests, in case they are useful there: a block re-sent on an id that was refused for maxSessionMemory (the refused id has to advance the mark too, which #37985 does), and an ignored block split across HEADERS + CONTINUATION whose dynamic-table insertion a later request references (pins the HPACK decode of the CONTINUATION half). Also one observation: in the earlier revision's late-trailers test (server calls stream.close() on a POST, client's trailers arrive afterwards) the Windows aarch64 lane saw one extra frame go out on the stream after the server's own DATA + RST_STREAM (https://buildkite.com/bun/bun/builds/92656), while Linux and macOS saw none, so the late block can apparently still meet a live entry there; #37985's same-read and later-read tests look like the right place to keep an eye on that.

Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread test/js/node/http2/h2-conformance.test.ts

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

@robobun

robobun commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed d7488a3 on top of the main merge: the last_stream_id comment is gone and the last_peer_stream_id comment is one line. The four promised id tests, the full conformance file and node-http2.test.js pass on the debug build.

On the node question: node v26.3.0 rejects both bad promises with ERR_HTTP2_ERROR ("Protocol error"). The only difference is the GOAWAY code node writes (INTERNAL_ERROR instead of PROTOCOL_ERROR). That comes from node's session.destroy(err), not from nghttp2. Details are in the review thread.

The CI failures at de8ab37 are not related to this change: bunx.test.ts gets a 404 from npm for electron-to-chromium-1.5.421.tgz, and the inspector test passed on retry.

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

@cirospaciari

Copy link
Copy Markdown
Member

@robobun please run the tests this PR adds or changes on Node.js v26.3.0 and report whether each one passes or fails.
They must pass on both Bun and Node v26.3.0, so fix any case that only passes on Bun.

…he GOAWAY code

node v26.3.0 rejects the same PUSH_PROMISE frames with ERR_HTTP2_ERROR
(errno -505, "Protocol error") but writes INTERNAL_ERROR in its GOAWAY,
because session.destroy(err) runs before nghttp2 sends its own. Assert
the error both runtimes surface so the tests pass on node too.
@robobun

robobun commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Ran the four tests this PR adds under Node.js v26.3.0. Method: the promised stream ids describe block, RawH2Server, requestHeaderBlock and the frame helpers copied verbatim out of h2-conformance.test.ts into one .mts file, with a small expect/test shim over node:assert (node strips the TS types itself). The same file also runs under bun.

Results with the tests as they were on the PR (d7488a3):

test node v26.3.0 bun (this PR) bun 1.4.3
promised id does not exceed the previous one fail pass fail
promised id of a pushed stream the server already reset fail pass fail
higher promised id accepted after an earlier reset pass pass pass
promised id below the client's own stream ids accepted pass pass pass

The two node failures were the same line: expect(goawayErrorCode(goaway)).toBe(ErrorCode.PROTOCOL_ERROR) got 2 (INTERNAL_ERROR). Node does reject both frames: nghttp2 reports "PUSH_PROMISE: invalid promised_stream_id", the session emits ERR_HTTP2_ERROR (errno -505, "Protocol error") and is destroyed. But node's onSessionInternalError calls session.destroy(err), and destroy with an Error always writes INTERNAL_ERROR into the GOAWAY, before nghttp2 would send its own PROTOCOL_ERROR one.

Fixed in 064097e: expectSessionProtocolError now waits for the GOAWAY without pinning its code, and asserts the session error both runtimes surface: { code: "ERR_HTTP2_ERROR", errno: -505, message: "Protocol error" } plus client.destroyed. The two test names say "fails the session" instead of "is a connection PROTOCOL_ERROR".

Results after the change:

test node v26.3.0 bun (this PR) bun 1.4.3
promised id does not exceed the previous one pass pass fail
promised id of a pushed stream the server already reset pass pass fail
higher promised id accepted after an earlier reset pass pass pass
promised id below the client's own stream ids accepted pass pass pass

The bun 1.4.3 column is the unfixed binary, so the two rejection tests still exercise the handle_push_promise change. The full h2-conformance.test.ts (74 tests) passes on the debug build. I also merged current main into the branch (c524dae), no conflicts.

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

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

Comment thread test/js/node/http2/h2-conformance.test.ts
Comment thread src/runtime/api/bun/h2/connection.rs

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