Skip to content

node:http2: reject request header blocks that fail RFC 9113 8.3.1 (empty/missing :path, :method, :scheme; CONNECT shape) - #34736

Merged
cirospaciari merged 2 commits into
mainfrom
farm/c43cecf7/h2-request-pseudo-header-validation
Jul 20, 2026
Merged

node:http2: reject request header blocks that fail RFC 9113 8.3.1 (empty/missing :path, :method, :scheme; CONNECT shape)#34736
cirospaciari merged 2 commits into
mainfrom
farm/c43cecf7/h2-request-pseudo-header-validation

Conversation

@robobun

@robobun robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

What

A raw-frame h2 client that sends a request with :path set to the empty string reaches the 'stream' handler with headers[':path'] === '' (and req.url === '' in the compat layer). RFC 9113 8.3.1 says :path "MUST NOT be empty" for http/https and that every non-CONNECT request must carry exactly one non-empty :method, :scheme and :path; node (via nghttp2's nghttp2_http_on_request_headers) answers with RST_STREAM(PROTOCOL_ERROR) and never dispatches.

Repro (node RSTs, Bun on main dispatches):

import http2 from 'node:http2'; import net from 'node:net';
const hs = s => { const b = Buffer.from(s); return Buffer.concat([Buffer.from([b.length]), b]); };
const hp = ps => Buffer.concat(ps.flatMap(([n,v]) => [Buffer.from([0x10]), hs(n), hs(v)]));
const fr = (t,f,sid,pl) => { const h = Buffer.alloc(9); h.writeUIntBE(pl.length,0,3); h[3]=t; h[4]=f; h.writeUInt32BE(sid,5); return Buffer.concat([h,pl]); };
const srv = http2.createServer();
srv.on('stream', (st, h) => { console.log('DISPATCHED', JSON.stringify(h)); st.respond({':status':200}); st.end(); });
srv.listen(0, '127.0.0.1', () => {
  const s = net.connect(srv.address().port, '127.0.0.1');
  s.on('connect', () => s.write(Buffer.concat([
    Buffer.from('PRI * HTTP/2.0\\r\\n\\r\\nSM\\r\\n\\r\\n'),
    fr(4,0,0,Buffer.alloc(0)),
    fr(1,0x5,1,hp([[':method','GET'],[':path',''],[':scheme','http'],[':authority','h']])),
  ])));
});
// bun main: DISPATCHED {":method":"GET",":path":"",":scheme":"http",":authority":"h"}
// node v26: (no dispatch, RST_STREAM PROTOCOL_ERROR on stream 1)

The same validator gap meant requests with :method/:scheme/:path missing entirely, a plain CONNECT carrying :scheme/:path, a CONNECT without :authority, or :protocol on a non-CONNECT all reached the handler too. All are rejected by node as a stream PROTOCOL_ERROR.

Cause

finish_header_block in the h2 engine (src/runtime/api/bun/h2/connection.rs) tracks which pseudo-headers were seen (for the duplicate check) but never checks that the required ones are present, and never checks for empty values. The decode loop validated per-field shape (duplicate, unknown, late, connection-specific, CR/LF/NUL) but not the per-block 8.3.1 requirements.

Fix

nghttp2-equivalent, one block in finish_header_block:

  • An empty pseudo-header value is malformed inline (check_pseudo_header semantics), so ":path": "" never counts as present.
  • After the decode loop, a request block (a server-received initial HEADERS or a client-received PUSH_PROMISE) is held to the 8.3.1 requirements: :method, :scheme, :path and :authority-or-Host for ordinary requests; :authority and no :scheme/:path for plain CONNECT; :method CONNECT + :authority for extended CONNECT (:protocol, RFC 8441).

A header_is_request flag distinguishes a request block from a trailer section (which 8.1 already forbids from carrying pseudo-headers) and from a client-received response block (which the 8.3.1 rules do not apply to).

The rejection takes the existing malformed-block path (RST_STREAM(PROTOCOL_ERROR), counted against maxSessionInvalidFrames, never surfaced to 'stream'), so the JS-visible behavior matches node exactly.

Verification

16 new rejection cases plus 4 positive cases (valid block, host in place of :authority, plain CONNECT, extended CONNECT) in test/js/node/http2/h2-conformance.test.ts. All 16 rejection cases fail on the unfixed build (the request is dispatched) and pass with the fix; all 4 positive cases pass on both. The full h2-conformance suite (58 tests), node-http2.test.js (305 tests) and node's upstream test-http2-connect-method*/test-http2-misused-pseudoheaders pass on the fixed build.

Related

#33191 carries a broader version of this validation (including nghttp2's check_path() :path-starts-with-/ rule, Host empty/repeated handling, and PUSH_PROMISE connection-error semantics) as item 1 of a larger hardening round. This PR is the minimal, standalone carve-out of the 8.3.1 presence/emptiness rules against current main, shaped so #33191 rebases cleanly over it.


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

fails on main (without fix)
ASAN without fix: 16 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/http2/h2-conformance.test.ts"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (26eb1c855)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [429.38ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [145.80ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [117.38ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [87.99ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§
... (truncated)

release without fix: 29 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [8.02ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [2.39ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [1.39ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [0.92ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [0.86ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [2.33ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [0.82ms]
(pass) PING (checklist §3.7) > a PING on a non-zero stream id is a PROTOCOL_ERROR [0.72ms]
(pass) WINDOW_UPDATE (checklist §6) > a connection-level WINDOW_UPDATE with a 0 increment is a PROTOCOL_ERROR [0.71ms]
(pass) WINDOW_UPDATE
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/http2/h2-conformance.test.ts"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (26eb1c855)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [451.77ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [148.56ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [133.99ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [90.24ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 750ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[1/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m    Blocking�[0m waiting for file lock on build directory
�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
�[1m�[
... (truncated)
diff hotspot
src/runtime/api/bun/h2/connection.rs      |  89 +++++++++--
 test/js/node/http2/h2-conformance.test.ts | 242 ++++++++++++++++++++++++++++++
 2 files changed, 318 insertions(+), 13 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                       reads  edits  tests
src/runtime/api/bun/h2/connection.rs           6     13      0
test/js/node/http2/h2-conformance.test.ts      5      1      0

…pty/missing :path, :method, :scheme; CONNECT shape)

The h2 server's header-block validator checked for duplicate, unknown and
late pseudo-headers but never enforced the per-request presence rules in
RFC 9113 8.3.1: a HEADERS block with :path set to the empty string (or
missing any of :method/:scheme/:path, or a CONNECT carrying :scheme/:path,
or a non-CONNECT carrying :protocol) was delivered to the 'stream' handler
with req.url === '' / undefined. Node (via nghttp2's
nghttp2_http_on_request_headers) answers all of these with a stream
PROTOCOL_ERROR and never dispatches. A compliant peer cannot produce these
shapes, so nothing legitimate is rejected; the only callers that can reach
the handler with them are raw-frame clients.

The fix is nghttp2-equivalent: an empty pseudo-header value is malformed
inline, and after the decode loop a request block (a server-received
initial HEADERS or a PUSH_PROMISE) is held to the 8.3.1 requirements
before being surfaced.
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 13 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 82e714e0-0663-4722-b521-7e1ce3800303

📥 Commits

Reviewing files that changed from the base of the PR and between 99fc2f8 and 26eb1c8.

📒 Files selected for processing (2)
  • src/runtime/api/bun/h2/connection.rs
  • test/js/node/http2/h2-conformance.test.ts

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

@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:38 PM PT - Jul 19th, 2026

@autofix-ci[bot], your commit 26eb1c8 has 2 failures in Build #75867 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34736

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

bun-34736 --bun

@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:http/http2 hardening: enforce h2 request pseudo-headers, never report a failed handler as success, and bound/validate the parser paths (+1 upstream test) #33191 - Broader HTTP/2 hardening PR that includes a superset of the same inbound request pseudo-header validation in connection.rs

Generated with Claude Code

<!~~ find-duplicate-prs-bot:marker ~~>

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

No issues found, but this is protocol-validation logic in the h2 engine with a stated overlap with #33191, so it's worth a maintainer look to confirm the CONNECT / extended-CONNECT / :authority-vs-Host shape matches what #33191 expects to rebase over.

What was reviewed:

  • Traced the new §8.3.1 bitmask check against every test case (plain/extended CONNECT, missing/empty pseudo-headers, host fallback) — each branch reaches the intended verdict.
  • header_is_request = is_server && is_new correctly excludes client-side response HEADERS and server-side trailers; PUSH_PROMISE sets it explicitly.
  • The new value_b.is_empty() check also fires on client-side :status — that's a strict tightening matching nghttp2's check_pseudo_header, not a regression.
  • Existing Rust unit tests were updated to send complete pseudo-header sets; no other in-tree callers of encode_header/finish_header_block needed changes.
Extended reasoning...

Overview

Adds RFC 9113 §8.3.1 request pseudo-header validation to the h2 engine's finish_header_block in src/runtime/api/bun/h2/connection.rs: empty pseudo-header values are marked malformed inline in the decode loop, and after decode a request block (server-received initial HEADERS or client-received PUSH_PROMISE) is checked for the required combination of :method/:scheme/:path/:authority-or-Host, plain-CONNECT shape, and extended-CONNECT (RFC 8441) shape. A new header_is_request connection field distinguishes request blocks from trailers and client-side response blocks. The magic bit constants were lifted into a pseudo module. Four existing Rust unit tests were updated to send complete pseudo-header sets. 20 new cases (16 rejections + 4 acceptances) were added to test/js/node/http2/h2-conformance.test.ts.

Security risks

This is validation of untrusted network input. The change strictly tightens what reaches the JS 'stream' handler — previously an empty :path or missing :method reached user code, which is a request-smuggling / handler-confusion surface. The rejection path is the pre-existing malformed-block path (RST_STREAM PROTOCOL_ERROR, counted against maxSessionInvalidFrames), so no new DoS surface is introduced. I checked that the empty-value check is applied per-pseudo-header (matching nghttp2's check_pseudo_header) and that seen_pseudo is still set even when malformed, which is harmless because the request-shape check is gated on !malformed.

Level of scrutiny

Medium-high. This is not a mechanical change: the three-branch bitmask check (plain CONNECT vs extended CONNECT vs ordinary request) encodes several spec paragraphs and the interaction between saw_connect, extended_connect, saw_host, and the AUTHORITY bit is subtle. I traced each of the 20 test inputs through the logic and they all land where intended, including the extended-CONNECT-with-host-but-no-:authority case (rejected via the extended_connect && (seen_pseudo & AUTHORITY) == 0 clause even though saw_host would satisfy the ordinary-request clause). The is_server && is_new gate on header_is_request is essential and correctly commented — without is_server, every client-side response would be misclassified as a request since the engine only tracks inbound-created streams.

Other factors

The PR description explicitly positions this as a carve-out from #33191 designed to rebase cleanly under it, and the duplicate-PR bot flagged the overlap. That coordination decision — whether to land this standalone or fold it into #33191, and whether the exact validation shape here (e.g. no :path-starts-with-/ check, no empty/duplicate Host check) is the right cut point — is a maintainer call. The test coverage is thorough and the robobun gate confirms the tests fail without the fix and pass with it on both ASAN and release builds.

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

CI on build #75867: the two red tests are unrelated to this diff (test-net-connect-memleak.js is pre-existing on main on the alpine lanes; require-cache.test.ts is a 66 MB vs 64 MB memory-threshold flake on darwin 26 aarch64, in the require/transpile path this PR does not touch). No http2 test failed on any lane. Both reds are already being tracked separately.

h2-conformance.test.ts (58 tests, including the 20 new §8.3.1 cases) and node-http2.test.js (305 tests) pass locally on the debug+ASAN build.

@cirospaciari
cirospaciari merged commit 6062448 into main Jul 20, 2026
77 of 80 checks passed
@cirospaciari
cirospaciari deleted the farm/c43cecf7/h2-request-pseudo-header-validation branch July 20, 2026 18:05
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