Skip to content

node:http2: fix connection-specific and pseudo-header validation on outbound header blocks - #33458

Closed
robobun wants to merge 5 commits into
mainfrom
farm/69fa2f21/http2-connection-headers
Closed

robobun wants to merge 5 commits into
mainfrom
farm/69fa2f21/http2-connection-headers

Conversation

@robobun

@robobun robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

import http2 from "node:http2";
// server: a plain node http2.createServer answering 200 to everything
const ses = http2.connect(`http://127.0.0.1:${port}`);
ses.on("error", () => {});
for (const [tag, h] of [
  ["connection", { connection: "keep-alive" }],
  ["http2-settings", { "http2-settings": "AAMAAABkAAQAoAAAAAIAAAAA" }],
  ["status-in-request", { ":status": "200" }],
]) {
  try {
    const st = ses.request({ ":path": "/x", ...h }, { endStream: true });
    st.on("error", e => console.log(tag, "async", e.code));
    st.resume();
  } catch (e) {
    console.log(tag, "SYNC-THROW", e.code);
  }
}
node v26.3.0 bun 1.4.0 / main
connection / upgrade / keep-alive / proxy-connection / transfer-encoding / te: gzip SYNC-THROW ERR_HTTP2_INVALID_CONNECTION_HEADERS stream, async ERR_HTTP2_STREAM_ERROR, rst=1
http2-settings SYNC-THROW ERR_HTTP2_INVALID_CONNECTION_HEADERS stream, 200, sent on the wire
:status in a request stream, peer RSTs with PROTOCOL_ERROR SYNC-THROW ERR_HTTP2_INVALID_PSEUDOHEADER

The node-idiomatic try { session.request(h) } catch never fires for a request that can never succeed, so the error surfaces later as what looks like a peer failure and retry logic keeps replaying a permanently-invalid request. Accepting http2-settings puts a connection-specific field on the wire, which conforming HTTP/2 peers may treat as a protocol error for the whole session (RFC 9113 §8.2.2).

Cause

Node routes every outbound header block through buildNgHeaderString, which calls isIllegalConnectionSpecificHeader on each non-pseudo field. Bun has the equivalent helper, assertNoConnectionHeaders, but it was only wired into pushStream(). The other four sites that encode a header block never called it:

site node bun before
session.request() throws no check
stream.respond() throws no check
stream.additionalHeaders() throws no check
stream.sendTrailers() throws no check
stream.pushStream() throws throws

The :status case is the same validation read in the other direction. Node's kValidPseudoHeaders holds all six spec-defined pseudo-headers and is what both request and PUSH_PROMISE blocks validate against, so :status is encoded and the peer reports the protocol error. Bun's native encoder validated an outbound request against the request-only set (h2_frame_parser.rs, is_valid_request_pseudo_header) and threw ERR_HTTP2_INVALID_PSEUDOHEADER synchronously.

Fix

  • src/js/node/http2.ts: call assertNoConnectionHeaders() from request(), respond(), additionalHeaders() and sendTrailers(), and give it node's value semantics: an undefined value or an empty array encodes no header at all, a single-element array unwraps, and a multi-element te array is illegal. Header names are matched case-insensitively, as before.
  • src/runtime/api/bun/h2_frame_parser.rs: add is_valid_pseudo_header (node's kValidPseudoHeaders) and use it for outbound request and PUSH_PROMISE blocks. Server responses keep is_valid_response_pseudo_header, and the inbound decoder keeps the strict request/response split, so a received request carrying :status is still malformed.

pushStream() is unchanged; it already had the check. respondWithFile/respondWithFD inherit it through respond() and destroy the stream on the throw, matching node's processRespondWithFD. The two doSendFileFD error branches (fstat failure, directory/non-seekable target) called respond() before destroy(), so a throwing respond() skipped the teardown; both are now guarded so the stream is always torn down.

Verification

bun bd test test/js/node/http2/node-http2.test.js passes; the five new tests fail on an unpatched build.

The repro above now prints the same table as node v26.3.0 for all ten shapes (the six connection-specific fields, te: gzip, te: trailers, http2-settings, :status), including the raw [name, value, ...] headers form and the value-shape matrix (te: ["trailers"] allowed, te: ["trailers", "gzip"] rejected, connection: [] skipped).

Ported node http2 suite: no change

All 272 test/js/node/test/parallel/test-http2-*.js files were run before and after. The same set passes; test-http2-misused-pseudoheaders, test-http2-info-headers, test-http2-server-push-stream-errors-args and test-http2-invalidheaderfield (which assert on these error codes) pass both ways. test-http2-util-headers-list, test-http2-util-assert-valid-pseudoheader and test-http2-respond-file-fd-leak fail identically on stock bun 1.4.0 and are unrelated (they require internalBinding("http2") / internal/http2/util, or cover an fd leak on respondWithFile's error path).

Rebase note

Main wrapped assertSingleValueHeaders(headers) in a kStrictSingleValueFields !== false conditional at the same three call sites this change inserts assertNoConnectionHeaders(headers) after. Node's buildNgHeaderString gates the single-value check on that flag but calls isIllegalConnectionSpecificHeader unconditionally, so the resolution keeps main's conditional for the single-value check and calls assertNoConnectionHeaders unconditionally after it.


[review] gate passed · iteration 16 · 3 files touched

fails on main (without fix)
ASAN without fix: 12 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 (0691f8e6f)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [869.61ms]
(pass) node none > Client Basics > should be able to send a POST request [608.10ms]
(pass) node none > Client Basics > constants [19.00ms]
(pass) node none > Client Basics > getDefaultSettings [7.81ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [22.37ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.15ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.16ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [5.05ms]
(pass) node none > Client Basics > should be able to send data using end [639.17ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [628.07ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving 
... (truncated)

release without fix: 101 failed, 6 skipped
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [1.48ms]
(pass) node none > Client Basics > getDefaultSettings [0.18ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.50ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.16ms]
(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.09ms]
(pass) node none > Client Basics > headers cannot be bigger than 65536 bytes [2.08ms]
(pass) node none > Client Basics > is possible to abort request [1.10ms]
(pass) node none > Client Basics > aborted event should work with abortController [0.75ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [0.62ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [1.02ms]
782 |           client.on("error", reject);
783 |           const req = client.request({ ":path": "/", "test-hea
... (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 (0691f8e6f)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [884.72ms]
(pass) node none > Client Basics > should be able to send a POST request [627.51ms]
(pass) node none > Client Basics > constants [19.06ms]
(pass) node none > Client Basics > getDefaultSettings [7.13ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [22.19ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [4.87ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.45ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [4.95ms]
(pass) node none > Client Basics > should be able to send data using end [656.02ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [647.21ms]
(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     0691f8e6f8
  features     baseline

22 deps, 108 codegen, 1171 objects in 1099ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [13.00ms]
[2/1234] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [3.00ms]
[3/1234] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [21.00ms]
[4/1234] gen ErrorCode+*.h
[5/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[6/1234] fetch picohttpparser
[picohttpparser] up to date
[7/1234] gen bindgenv2
[8/1234] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[9/1234] fetch zlib
[zlib] up to date
[10/1234] gen .bind.ts → Gene
... (truncated)
diff hotspot
src/js/node/http2.ts                   |  27 +++-
 src/runtime/api/bun/h2_frame_parser.rs |  23 ++-
 test/js/node/http2/node-http2.test.js  | 288 +++++++++++++++++++++++++++++++++
 3 files changed, 328 insertions(+), 10 deletions(-)

gate history · 4 passed · 1 rejected · iteration 16

evidence per changed file
file                                    reads  edits  tests
src/js/node/http2.ts                        8     14      0
src/runtime/api/bun/h2_frame_parser.rs      6      8      0
test/js/node/http2/node-http2.test.js       8      7      0

@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:20 AM PT - Jul 29th, 2026

❌ @robobun, your commit 0691f8e has 3 failures in Build #85014 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33458

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

bun-33458 --bun

@github-actions github-actions Bot added the claude label Jul 6, 2026
@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR adds outbound HTTP/2 connection-specific header validation across JS-side stream send paths (sendTrailers, additionalHeaders, respond, request) via a reworked assertNoConnectionHeaders(), and unifies pseudo-header validation in the Rust frame parser via a new name-based PSEUDO_HEADERS set and is_valid_pseudo_header(). Tests were added to cover both behaviors.

Changes

HTTP/2 header validation hardening

Layer / File(s) Summary
assertNoConnectionHeaders implementation
src/js/node/http2.ts
Reworks header validation to iterate object keys, treat undefined/empty-array values as absent, tighten te value checking, and throw ERR_HTTP2_INVALID_CONNECTION_HEADERS on violations; updates the nearby RFC 9113 comment.
Wiring validation into HTTP/2 send paths
src/js/node/http2.ts
Calls assertNoConnectionHeaders() from sendTrailers(), ServerHttp2Stream.additionalHeaders(), ServerHttp2Stream.respond(), and ClientHttp2Session.request() before headers are encoded or sent.
Unified pseudo-header validation in Rust frame parser
src/runtime/api/bun/h2_frame_parser.rs
Adds a PSEUDO_HEADERS set and is_valid_pseudo_header() helper, and switches push_promise and request (both raw-pairs and object/iterator paths) from context-specific request/response validation to this unified name-based check.
Test coverage for header validation
test/js/node/http2/node-http2.test.js
Adds tests for synchronous rejection of connection-specific headers on the client, non-throwing behavior for outbound :status (with peer-side NGHTTP2_PROTOCOL_ERROR), and server-side stream API rejection of connection-specific headers.

Possibly related PRs

  • oven-sh/bun#29075: Both PRs modify the same HTTP/2 send paths in src/js/node/http2.ts (sendTrailers(), ServerHttp2Stream.respond()).
  • oven-sh/bun#33072: Overlaps directly with this PR's assertNoConnectionHeaders validation in http2.ts and pseudo-header validation in h2_frame_parser.rs, plus corresponding tests.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed [#39] The change improves Node.js compatibility by matching Node's HTTP/2 outbound header behavior, which fits the linked issue's goal.
Out of Scope Changes check ✅ Passed All code and tests relate to Node HTTP/2 outbound validation and pseudo-header compatibility; no clear unrelated changes stand out.
Title check ✅ Passed The title is concise and accurately summarizes the main HTTP/2 outbound header validation changes.
Description check ✅ Passed The description covers the change, root cause, fix, and verification, so it is mostly complete despite not using the exact template headings.

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

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/node/http2.ts`:
- Around line 3219-3221: Guard the file-response paths in respondWithFile() and
respondWithFD() the same way as the other outbound header encoders: after
headers are normalized/defaulted and before any file I/O or native send-file
call, invoke assertNoConnectionHeaders() on the final headers object. This
should ensure connection-specific fields, http2-settings, and invalid te values
cannot bypass validation through file responses.

In `@test/js/node/http2/node-http2.test.js`:
- Around line 2594-2598: The inline rationale added around the HTTP/2
invalid-headers case is too long and exceeds the repo’s 3-line comment limit.
Shorten the comment in the HTTP2 test around the request/session.request(h)
check to a concise 3-line summary, or move the extra RFC and retry-context
explanation into the PR description while keeping only the essential note in the
test.
- Around line 2652-2659: Add the missing undefined coverage to the HTTP/2 header
test in node-http2.test.js. The existing assertion around the tryRequest cases
in the HTTP/2 test block documents that undefined values should not be
forbidden, but only verifies empty arrays and other inputs. Update the same
expect(...) call near the te/trailers cases to include a connection: undefined
case (using tryRequest with the same helper) so the test explicitly guards
against regressions in handling undefined header values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d0b5a802-8ea2-4ace-8914-58ae2a37f8c3

📥 Commits

Reviewing files that changed from the base of the PR and between 48ff9eb and 8979e2d.

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

Comment thread src/js/node/http2.ts Resolved
Comment thread test/js/node/http2/node-http2.test.js Resolved
Comment thread test/js/node/http2/node-http2.test.js Resolved
Comment thread test/js/node/http2/node-http2.test.js Resolved
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks both. I checked each finding at the wire level; two of the four are real, two are based on code paths that don't run. Pushed 1930c53 with the fixes that were warranted.

Addressed

Comment over the 3-line limit (node-http2.test.js:2594) — trimmed to 3 lines.

respondWithFile() / respondWithFD() coverage — the guard was already in effect there, but the coverage gap was real, so I added a test. Both entry points build their header block through doSendFileFD → respond(), which is where the check now lives. Verified against node v26.3.0:

node v26.3.0 bun (this PR)
respondWithFile(path, { connection }) no sync throw no sync throw
server stream 'error' ERR_HTTP2_INVALID_CONNECTION_HEADERS ERR_HTTP2_INVALID_CONNECTION_HEADERS
server rstCode 2 (INTERNAL_ERROR) 2 (INTERNAL_ERROR)
client stream 'error' ERR_HTTP2_STREAM_ERROR ERR_HTTP2_STREAM_ERROR

So I did not take the suggested synchronous assertNoConnectionHeaders() call in respondWithFile()/respondWithFD(): node encodes the header block only after the fstat completes (processRespondWithFD) and destroys the stream with the error, so a synchronous throw there would be a new divergence. The new test pins the async shape on both entry points, and fails on an unpatched build (serverErrors.file is ERR_HTTP2_STREAM_ERROR because the client RSTs, and the fd case with http2-settings succeeds outright — the on-the-wire bug this PR fixes).

Not applied, with evidence

connection: undefined should assert null — it doesn't today, so that assertion would fail. Bun rejects undefined values for every header name, not just connection-specific ones:

                        node v26.3.0    bun 1.4.0 / main / this PR
connection: undefined   no-throw        THROW ERR_HTTP2_INVALID_HEADER_VALUE
x-foo:      undefined   no-throw        THROW ERR_HTTP2_INVALID_HEADER_VALUE

That is generic header-value handling in the native encoder, unchanged before and after this PR. The value === undefined skip in assertNoConnectionHeaders is still node's rule and still does something observable (without it, { connection: undefined } would report ERR_HTTP2_INVALID_CONNECTION_HEADERS instead of failing like any other undefined-valued header), so I added the case asserting the behavior that actually exists, with a note that the remaining divergence is separate.

:status triggers GOAWAY via the decode_header_block() special-case at h2_frame_parser.rs:3794 — that function is not on the inbound HEADERS path, so the branch never runs. The live inbound decoder is the rewrite_read engine (on_header / on_headers_complete); decode_header_block is the legacy path. I put a temporary eprintln! on that exact line, rebuilt, and it printed nothing for a request carrying :status.

Captured at the wire with a raw socket client against a Bun http2.createServer(), sending :status both as static-table index 0x88 and as a new-name HPACK literal:

=== bun server, :status sent as literal ===     === node server, :status sent as literal ===
  <- SETTINGS stream=0                            <- SETTINGS stream=0
  <- SETTINGS stream=0                            <- SETTINGS stream=0
  <- RST_STREAM stream=1 code=1                   <- RST_STREAM stream=1 code=1

Identical for the indexed encoding, and no GOAWAY frame in either. The session survives, the follow-up request is delivered, and the test passes deterministically (full file: 304 tests, 298 pass, 0 fail). I left the dead branch alone rather than delete code I can't prove unreachable from every caller.

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

Thanks for the wire-capture evidence on the :status inbound path — that resolved my earlier concern. I didn't find any remaining issues, but since this touches HTTP/2 header-block validation across both the JS layer and the native frame encoder (relaxing outbound pseudo-header checks), I'd like a human maintainer to sign off.

Extended reasoning...

Overview

This PR aligns Bun's node:http2 outbound header validation with Node.js in two directions:

  • JS (src/js/node/http2.ts): adds assertNoConnectionHeaders() calls to the four sites that encode a header block but previously skipped the check — request(), respond(), additionalHeaders(), sendTrailers() — and refines the helper to match Node's value semantics (skip undefined/empty-array values, compare te via String(value) so ["trailers"] unwraps and ["trailers","gzip"] fails). Also relocates a misplaced doc comment.
  • Rust (src/runtime/api/bun/h2_frame_parser.rs): adds an is_valid_pseudo_header predicate holding all six spec-defined pseudo-headers (Node's kValidPseudoHeaders) and swaps three outbound-encoding sites (client request object form, client request raw-array form, server PUSH_PROMISE) from the request-only set to it. Server response encoding and inbound decoding are unchanged.
  • Tests: four new tests (~260 lines) covering the full matrix — every forbidden connection-specific field, te value shapes, raw-array headers, :status in a client request, respond/additionalHeaders/sendTrailers, and respondWithFile/respondWithFD.

Security risks

None material. The JS side tightens validation (rejects RFC 9113 §8.2.2 connection-specific headers earlier, before they reach the wire). The Rust side relaxes the outbound client/PUSH_PROMISE pseudo-header check to allow :status through — but that only lets a client send a request the peer will RST; it is Node's documented behavior and does not affect what Bun's server accepts inbound. No auth, crypto, or permissions surface is touched.

Level of scrutiny

Moderate-to-high. HTTP/2 frame encoding is production-critical network code, and the interactions here (sync throw vs. async stream error, RST_STREAM vs. GOAWAY, which validation predicate runs at which site) are subtle — subtle enough that my own earlier static-reading of decode_header_block reached a conclusion the author refuted with a wire capture and an eprintln probe. The changes themselves are small and mechanical (four one-line insertions, one predicate swap at three sites), but the correctness argument depends on understanding which encoder/decoder paths are live, and that warrants a maintainer familiar with h2_frame_parser.rs.

Other factors

All prior review feedback is resolved: the three CodeRabbit comments were addressed in commit 1930c53 (comment shortened, undefined-value case covered, respondWithFile/respondWithFD test added), and my inline concern about the :status test was answered with empirical evidence (raw-socket capture showing RST_STREAM, not GOAWAY, from Bun's server; full-file run of 304 tests with 0 failures; all four new tests fail on an unpatched build). The PR description also reports no change across the 272 ported test-http2-* Node parallel tests. No CODEOWNERS entry covers these paths. Given the subsystem, I'm deferring rather than approving.

@robobun
robobun force-pushed the farm/69fa2f21/http2-connection-headers branch from 1057436 to 79d3645 Compare July 6, 2026 20:39
Comment thread src/js/node/http2.ts Resolved

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

Thanks — 8f7089d addresses the send-file error-path teardown and I found nothing new; leaving final sign-off to a human since this changes HTTP/2 wire behavior on both the JS validation side and the native outbound pseudo-header check.

Extended reasoning...

Overview

This PR wires assertNoConnectionHeaders() into the four outbound header-block sites that were missing it (request(), respond(), additionalHeaders(), sendTrailers()) in src/js/node/http2.ts, reworks that helper's value semantics (own-key iteration via ObjectKeys, skip undefined/empty-array, String(value) coercion for te), guards the two doSendFileFD error branches so a throwing respond() cannot skip stream teardown, and — on the native side — adds is_valid_pseudo_header() in h2_frame_parser.rs and swaps three outbound encode sites (request object path, request raw-pairs path, PUSH_PROMISE) from the request-only set to the full spec set, so :status in a request is encoded rather than thrown. Five new tests cover client-side sync rejection, the :status-on-request wire shape, server-side respond/additionalHeaders/sendTrailers, the respondWithFile/FD async-error shape, and the directory + connection-header teardown regression.

Security risks

The net effect is stricter outbound validation on the JS side (connection-specific fields are rejected before encoding, closing the case where http2-settings previously reached the wire) and looser outbound validation on the native side (:status in a request is now sent). The relaxation matches Node's kValidPseudoHeaders and the peer is expected to RST the stream per RFC 9113 §8.1.1; the inbound decoder and server-response pseudo-header check are unchanged. I do not see an injection, auth, or data-exposure vector, but any change to what an HTTP/2 client will emit on the wire is worth a maintainer's eye.

Level of scrutiny

Medium-high. This is a focused Node-compat fix with strong test coverage and wire-level verification tables against Node v26.3.0, and the ported test-http2-* suite is reported unchanged. But it is a behavioral change in a network protocol path spanning both the JS builtin and the native frame encoder — not a mechanical or config-only change — so it falls outside what I would auto-approve.

Other factors

Both of my earlier inline findings are resolved: the :status → GOAWAY concern was refuted empirically (the cited decode_header_block() branch is not on the live inbound path), and the doSendFileFD error-branch hang was fixed in 8f7089d with a dedicated regression test. All CodeRabbit threads are resolved, and the bug-hunting pass on this revision found nothing. No CODEOWNERS entry matches the touched files.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: diff is green, one lane failing on an infra flake

The latest run (build #69355, sha 8f7089d3) has 278 jobs passing and a single red lane: darwin 26 aarch64 - test-bun (parallel shard 0/2).

That shard ran zero tests. It died during setup:

Searching for artifacts: "**" within step: "darwin-aarch64-build-bun"
Found 2 artifacts. Starting to download to: .../bun/bun/release
Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

Evidence it's infra, not the diff:

  • Buildkite tagged the build annotation flaky.
  • The artifact producer darwin aarch64 - build-bun passed, so the binary exists; only this agent's S3 download timed out.
  • The sibling shard darwin 26 aarch64 - test-bun (1/2) passed on the same platform and binary.
  • The identical artifact download timed out after 120s failure hit build #68934 on this branch too.
  • The full linux (gnu/musl/asan/baseline), windows, and alpine test matrix is green.

I've already used my one allowed CI re-roll on this flake, so I'm not pushing another retrigger (that would just be noise). The diff itself is complete and verified: test/js/node/http2/node-http2.test.js is 305 tests, 299 pass / 0 fail locally, and the new tests fail on an unpatched build. A maintainer re-running the darwin 26 aarch64 lane should clear it.

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Another report of the trailer-block face of this landed today; I worked it before finding this PR. Pushed the branch for reference: claude/c69db838/http2-connection-header-validation.

Same core fix; a few deltas that might be worth folding in here:

  • Compat setTrailer drops connection after the UnsupportedWarning (matching the existing [kSetHeader] behavior), so res.addTrailers({connection: ...}) yields a clean response instead of the warning claiming the value is dropped while it still reaches sendTrailers.
  • onStreamTrailersReady wraps sendTrailers in try/destroy: with the new validation a compat res.addTrailers({'keep-alive': ...}) would otherwise throw from inside _final's emit('wantTrailers') and leave the stream without END_STREAM (hangs in Bun; node surfaces it as an uncaughtException).
  • Folded the forbidden-field check into the existing assertSingleValueHeaders loop so respond()/request() don't iterate the header keys twice (the 10000-request maxSessionMemory test in node-http2.test.js is close enough to its debug-build timeout that a second full key walk pushed it over).
  • Routed the error through ErrorCode.ts as $ERR_HTTP2_INVALID_CONNECTION_HEADERS.

Not opening a separate PR.

@robobun
robobun force-pushed the farm/69fa2f21/http2-connection-headers branch from 8f7089d to 129b8e5 Compare July 17, 2026 04:13
Comment thread src/js/node/http2.ts Resolved
Comment thread src/js/node/http2.ts Resolved
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: diff is green; all red lanes are unrelated and already owned

Build #74378 (sha 129b8e5c, post-rebase) finished with 281 jobs passing. None of this PR's http2 tests failed. The four red lanes are all unrelated to this diff and already handled elsewhere:

lane what failed relation to this diff
debian 13 x64-asan test-worker-message-port-transfer-terminate.js SIGABRT, JSC !scope.exception() assertion worker test, does not load http2; passes 5/5 on my ASAN build locally
debian 13 x64-asan test-http2-reset-flood.js SIGABRT, JSC assertNoException() assertion uses no connection-specific headers; passes 5/5 on my ASAN build locally; also hits unrelated branch build #74304
darwin 14 aarch64 no-orphans.test.ts perl fast-exit daemon 30s timeout CLI process-reaping test, no http2
darwin 26 aarch64 runner crashed with uv_os_get_passwd returned ENOENT CI machinery (macOS user-db hiccup), not a test failure

The diff itself is complete and verified: test/js/node/http2/node-http2.test.js runs 315 tests, 309 pass / 0 fail locally, and all five new tests fail on an unpatched build. All review threads resolved.

I am not pushing another retrigger; a maintainer re-run of those lanes should clear them.

@robobun
robobun force-pushed the farm/69fa2f21/http2-connection-headers branch 2 times, most recently from c79ce99 to d81b991 Compare July 17, 2026 11:48
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: diff is green; both red tests are unrelated ASAN flakes already being handled

Build #74581 (sha d81b991f, latest rebase) finished with 284 jobs passing and two failures, both on debian 13 x64-asan:

test relation to this diff
test/js/web/timers/timer-heap-race.test.ts SIGABRT timer / Atomics.waitAsync race; does not touch http2
test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js SIGABRT worker-thread terminate race (JSC exception-scope assertion); does not load http2

Both pass repeatedly on my ASAN debug build locally, both are being addressed separately, and this PR touches only src/js/node/http2.ts, src/runtime/api/bun/h2_frame_parser.rs, and test/js/node/http2/node-http2.test.js. None of this PR's http2 tests failed on any lane.

test/js/node/http2/node-http2.test.js runs 316 tests, 310 pass / 0 fail locally, and all five new tests fail on an unpatched build. All review threads resolved. I'm not pushing another retrigger.

@robobun
robobun force-pushed the farm/69fa2f21/http2-connection-headers branch from d81b991 to 90aac4f Compare July 17, 2026 19:48
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Diff is ready; internal QA build is failing on infra

The internal QA gate bounced on 90aac4f1 with two "BUILD FAILED (no junit output)" legs:

  • ASAN-without-fix: build failed before any test ran.
  • release-with-fix: build failed before any test ran.
  • ASAN-with-fix: ran and passed (0 failures, 6 skipped).
  • release-without-fix: ran, but against a stale binary (d81b991f5, the previous branch head, which already carries the fix), so it produced no valid fail-before data.

I reproduced both builds locally on 90aac4f1:

$ bun run build:release
[117/117] strip bun
[build] done
$ ./build/release/bun test test/js/node/http2/node-http2.test.js
 310 pass
 6 skip
 0 fail
Ran 316 tests across 1 file. [1.91s]

bun bd test test/js/node/http2/node-http2.test.js also passes (310/0/6), and all five new tests fail when src/ is reverted to the merge-base. So the fix works under both profiles; the gate's two BUILD FAILED legs are build-environment instability, not the diff.

I am not pushing another empty commit to re-queue the gate (one was already pushed earlier for the same failure pattern, and each push also queues a full CI run).

robobun added 4 commits July 29, 2026 08:28
…utbound header blocks

Node validates every outbound header block through buildNgHeaderString, which
rejects connection-specific fields (connection, upgrade, keep-alive,
proxy-connection, transfer-encoding, http2-settings, and te with any value
other than "trailers") with a synchronous ERR_HTTP2_INVALID_CONNECTION_HEADERS.
Bun only wired that check into pushStream(), so client request(), respond(),
additionalHeaders() and sendTrailers() let those fields reach the wire: the
request came back as a stream that failed asynchronously, and http2-settings
was encoded into the HEADERS frame of a request node never sends.

Call assertNoConnectionHeaders() from the four missing sites, and give it
node's value semantics: undefined values and empty arrays encode no header at
all, and a multi-element te array is illegal.

The outbound pseudo-header set is the opposite problem. Node's
kValidPseudoHeaders accepts every spec-defined pseudo-header on a request or
PUSH_PROMISE block, so a request carrying :status is encoded and the peer
answers with a stream error. Bun validated against the request-only set and
threw ERR_HTTP2_INVALID_PSEUDOHEADER synchronously instead. The inbound
decoder keeps the strict request/response split.
respondWithFile()/respondWithFD() encode their header block through respond(),
so they pick up the connection-specific check; node validates at the same point
(processRespondWithFD) and destroys the stream instead of throwing, because the
block is built after the fstat completes. Assert that shape on both entry
points.

Also pin the undefined-value behavior: an undefined value encodes no header, so
it is not a connection-specific violation and fails the same way any
undefined-valued header does.
…spond

doSendFileFD's two error branches (fstat failure, and a directory / non-seekable
target) called this.respond(headers, options) before this.destroy(). respond()
validates headers and can throw, so a connection-specific header on one of these
paths skipped the destroy and leaked the stream, hanging the client. Guard both
respond() calls so the stream is always torn down, matching node, which never
encodes headers on the send-file error path.
@robobun
robobun force-pushed the farm/69fa2f21/http2-connection-headers branch from 90aac4f to 00fe292 Compare July 29, 2026 08:36
Comment thread src/js/node/http2.ts Resolved
Comment thread src/js/node/http2.ts Resolved
Comment thread src/js/node/http2.ts Resolved
Comment thread src/js/node/http2.ts Resolved
Comment thread src/runtime/api/bun/h2_frame_parser.rs Resolved
@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: superseded by #41614, which adds the connection-specific header check (with node's te rules) to request(), respond(), additionalHeaders() and sendTrailers(). This branch no longer merges.

@robobun robobun closed this Sep 6, 2026
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