Skip to content

node:http2: default a push's :authority from the request's host - #43298

Open
robobun wants to merge 3 commits into
mainfrom
robobun/7ffef172/http2-push-authority-from-host
Open

robobun wants to merge 3 commits into
mainfrom
robobun/7ffef172/http2-push-authority-from-host

Conversation

@robobun

@robobun robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • stream.pushStream() sends a PUSH_PROMISE with neither :authority nor host when the request carried host and no :authority. A node client resets each push. After 1001 requests its session fails with ERR_HTTP2_TOO_MANY_INVALID_FRAMES.
  • The cause: the :authority default in pushStream() (src/js/node/http2.ts:3247) reads only :authority, on the push headers and the request headers.
  • So a push with its own host also gets the request's :authority. Node sends only host.

Fix

  • Use the existing getAuthority() helper (:authority, else host) on both sides of the default, like node (core.js#L2929-L2930). Node reads the request side at stream creation. Bun still reads it at push time.
  • The second case is node parity only. RFC 9113 8.4.1 asks for :authority in every PUSH_PROMISE, and node sends only the user's host.
  • Verified: test/js/node/http2/node-http2.test.js (two new tests, both fail on bun 1.4.3). Also h2-conformance.test.ts, node-http2-client-close.test.ts, and 15 node parallel pushStream() tests.
  • Self-reviewed: 3 concerns raised, 3 addressed. Not changed here: the client ignores host for the origin that a 421 removes from originSet (node:http2: a 421 response removes the session's origin from originSet when the request used host #43294).

Background

  • A PUSH_PROMISE frame carries a request header block. The server answers it on a new stream.
  • A request names its target with :authority or with host. An RFC 7540 8.1.2.3 gateway sends only host. So does a node or Bun client when the user passes host.
  • nghttp2 rejects a request block that has neither. It resets the promised stream and counts an invalid frame. Bun's inbound engine rejects it too (src/runtime/api/bun/h2/connection.rs:1276).
Notes

Repro. The client half always runs under node, so only the server differs:

import http2 from "node:http2";
import { spawn } from "node:child_process";
if (process.argv[2] === "client") {
  const s = http2.connect("http://127.0.0.1:" + process.argv[3]);
  s.on("stream", (pushed, headers) => { console.log("client received PUSH_PROMISE:", JSON.stringify(headers)); pushed.resume(); });
  const r = s.request({ ":path": "/", host: "example.test" });
  r.resume(); r.on("close", () => s.close()); r.end();
} else {
  const srv = http2.createServer();
  srv.on("stream", (st, headers) => {
    console.log("server received request:", JSON.stringify(headers));
    st.pushStream({ ":path": "/pushed" }, (err, pushed) => {
      if (err) throw err;
      console.log("server pushed.sentHeaders:", JSON.stringify(pushed.sentHeaders));
      pushed.respond({ ":status": 200 }, { sendDate: false }); pushed.end("p");
      st.respond({ ":status": 200 }, { sendDate: false }); st.end("ok");
    });
  });
  srv.listen(0, "127.0.0.1", () => { const c = spawn("node", [import.meta.filename, "client", srv.address().port], { stdio: "inherit" }); c.on("exit", () => srv.close()); });
}

node v26.3.0 server:

server received request: {":path":"/",":method":"GET",":scheme":"http","host":"example.test"}
server pushed.sentHeaders: {":path":"/pushed",":method":"GET",":authority":"example.test",":scheme":"http"}
client received PUSH_PROMISE: {":path":"/pushed",":method":"GET",":authority":"example.test",":scheme":"http"}

bun 1.4.3 server (same on main):

server received request: {":path":"/",":method":"GET",":scheme":"http","host":"example.test"}
server pushed.sentHeaders: {":path":"/pushed",":method":"GET",":scheme":"http"}

With this change:

server received request: {":path":"/",":method":"GET",":scheme":"http","host":"example.test"}
client received PUSH_PROMISE: {":path":"/pushed",":method":"GET",":scheme":"http",":authority":"example.test"}
server pushed.sentHeaders: {":path":"/pushed",":method":"GET",":scheme":"http",":authority":"example.test"}

One session, 1200 sequential requests with host only, one push per request:

server client responses pushes session
node v26.3.0 node 1200 1200 ok
bun 1.4.3 node 1001 0 ERR_HTTP2_TOO_MANY_INVALID_FRAMES
bun 1.4.3 bun 1.4.3 99 0 ERR_HTTP2_SESSION_ERROR, code 11
this change node 1200 1200 ok
this change bun 1.4.3 1200 1200 ok

I did not trace the bun-to-bun row (which side sends code 11). This change removes the malformed pushes that start it. It does not change how either side handles a run of reset pushes.

Authority of the PUSH_PROMISE, same-process server and client (<port> is the listen port):

request push headers node v26.3.0 bun 1.4.3 this change
host only none :authority: example.test none, the client drops the push :authority: example.test
host only host: push.test host: push.test host: push.test (plus an :authority: undefined key on sentHeaders) host: push.test
default :authority host: push.test host: push.test :authority: 127.0.0.1:<port> and host: push.test host: push.test
default :authority none :authority: 127.0.0.1:<port> same same
:authority and host none the request's :authority same same
host only :authority: push.test :authority: push.test same same

How a request with host only arises. The object form request({ host }) sends only host on node and on Bun. The raw-array form request([":path", "/", "host", "example.test"]) sends only host on Bun. Node also sends the session's :authority there.

Not changed here.

  • When the request side is read. Node stores getAuthority(headers) when it creates the stream (core.js#L2887). Bun reads the stored request headers object at push time, and that object is the one the 'stream' listener receives. If the listener sets headers.host = "mutated.test" before it calls pushStream(), node pushes :authority: example.test and this branch pushes :authority: mutated.test. Bun 1.4.3 already does this for :authority (a listener that sets headers[":authority"] changes the push). This change extends the same read to host. No issue tracks this yet.
  • The client side (node:http2: a 421 response removes the session's origin from originSet when the request used host #43294): ClientHttp2Session.request() sets req.authority from :authority, else from the session. removeOriginFromSet() uses it after a 421. A fix needs a decision on the raw-array form and on empty values, so it is not part of this change.
  • The order of the defaulted pseudo-headers still differs from node (:method, :path, :scheme, :authority here, :method, :authority, :scheme, :path in node).
  • When the stored request headers have neither field, the default is undefined, as in node. The native header walk in push_promise skips undefined values (src/runtime/api/bun/h2_frame_parser.rs:6096). This was already the behavior. The only way there is a host value that strict field whitespace validation drops. nghttp2 rejects that whole request.

Test helper. pushedHeaderBlocks() gets an options argument (requestHeaders, pseudoHeaders). The shape of its blocks does not change, so other tests that compare whole blocks are not affected.

#43206 changes the adjacent line 3241 (where parentRequestHeaders comes from). It does not touch these lines.


[human-review] gate passed · iteration 1 · 2 files touched

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

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [1136.58ms]
(pass) node none > Client Basics > should be able to send a POST request [699.02ms]
(pass) node none > Client Basics > constants [18.81ms]
(pass) node none > Client Basics > getDefaultSettings [8.28ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [19.55ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [6.75ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [4.23ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [6.59ms]
(pass) node none > Client Basics > should be able to send data using end [706.80ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [693.70ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving 
... (truncated)

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

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [0.86ms]
(pass) node none > Client Basics > getDefaultSettings [0.15ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.26ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.12ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [0.03ms]
(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.95ms]
(pass) node none > Client Basics > aborted event should work with abortController [0.94ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [0.76ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [0.99ms]
(pass) node none > Client Basics > headers cannot be bigger than 65536 bytes [49.02ms]
(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/pr_gate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.3 (b52d51348)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [931.76ms]
(pass) node none > Client Basics > should be able to send a POST request [620.66ms]
(pass) node none > Client Basics > constants [28.01ms]
(pass) node none > Client Basics > getDefaultSettings [11.18ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [22.47ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.63ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.36ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [5.24ms]
(pass) node none > Client Basics > should be able to send data using end [655.44ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [641.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) in 836ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/127] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2.classes.ts
  - H2FrameParser (29 fields)
Found 9 classes from /workspace/bun/src/runtime/api/html_rewriter.classes.ts
  - HTMLRewriter (3 fields)
  - TextChunk (7 fields)
  - DocType (5 fields
... (truncated)
diff hotspot
src/js/node/http2.ts                  |  5 +--
 test/js/node/http2/node-http2.test.js | 61 +++++++++++++++++++++++++++++++----
 2 files changed, 58 insertions(+), 8 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                   reads  edits  tests
src/js/node/http2.ts                       6      2     23
test/js/node/http2/node-http2.test.js      4      7     22

pushStream() filled the PUSH_PROMISE :authority only from the request's
:authority. A request that carried host and no :authority (RFC 9113
8.3.1) produced a PUSH_PROMISE with no authority at all, which nghttp2
clients reject. A push that carried its own host also got the request's
:authority next to it.

Use getAuthority() for both the check on the push headers and the value
read from the request headers, like node's ServerHttp2Stream.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for the next 2 days.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for the next 2 days. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 19 minutes for your next included review.

Check out review usage here.

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 381d58db-74c0-47e3-b619-dad8a03fcb3b

📥 Commits

Reviewing files that changed from the base of the PR and between 367d939 and b2306e4.

📒 Files selected for processing (2)
  • src/js/node/http2.ts
  • test/js/node/http2/node-http2.test.js

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

@robobun

robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:19 AM PT - Sep 18th, 2026

❌ @robobun, your commit b2306e4 has 3 failures in Build #117783 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 43298

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

bun-43298 --bun

@robobun

robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: the fix is at b2306e4fe0. Its tests pass in CI, and no lane reports an http2 failure in either run. The PR is ready for a maintainer. The one review thread (comment length) is fixed and resolved.

CI

  • Build 117783 (b2306e4fe0): test/js/node/http2/node-http2.test.js ran on the x64-asan lane with 395 pass and 0 fail. The build is finished: 178 of 181 jobs passed. The three failed jobs each failed on one test. The three tests are red on every retry, and none of them loads node:http2:
    • test/bake/deinitialization.test.ts (windows 11 aarch64, segmentation fault at address 0x0). The crash report lists the loaded builtins, and node:http2 is not among them. The same test is red today in 11 builds of other branches and in build 117538 on main.
    • test/cli/run/require-cache.test.ts (x64-asan, 60 s timeout on 4 of 4 attempts). It is red in three other builds that ran at the same time (117778, 117779, 117781).
    • test/js/bun/util/inspect-error-leak.test.js (x64-asan, 10 s timeout on 4 of 4 attempts). It printed the same RSS growth as in build 117739, where it passed in 5.56 s. Between the two builds the source differs by two deleted comment lines.
  • Build 117739 (137c596d62): 180 of 181 jobs passed, and node-http2.test.js passed with 395 pass and 0 fail. The failed job (x64-asan) failed on test/js/bun/spawn/spawn.test.ts. Correction: I first called that test a break on main, then said it fails now and then. Both were imprecise. The last ten finished builds on main have no error for it, and it is red today in 24 builds of about 20 other branches.
  • All four tests are reported as failures that this diff does not cause. I used my one CI retrigger before build 117783, so I will not start another run.

Reproduction

  1. Save the script from the Notes block of the PR body as push-host.mjs. The client half always runs under node.
  2. Run node push-host.mjs. The node v26.3.0 server sends :authority: example.test, and the client prints client received PUSH_PROMISE: ....
  3. Run bun push-host.mjs. With bun 1.4.3 the client prints no client received PUSH_PROMISE line, and pushed.sentHeaders has no :authority.
  4. With this branch, the client receives the PUSH_PROMISE with :authority: example.test.

Tests

  • bun bd test test/js/node/http2/node-http2.test.js -t "pushStream" passes with the fix: 9 pass.
  • USE_SYSTEM_BUN=1 bun test test/js/node/http2/node-http2.test.js -t "pushStream" runs bun 1.4.3: 7 pass, 2 fail. The two failures are the new tests.

Not in this PR: the client has the same gap for the origin that a 421 removes from originSet. #43294 tracks it.

Known difference from node that stays: node reads the authority of the request when it creates the stream. Bun reads it at push time, as bun 1.4.3 already does for :authority. A 'stream' listener that edits the request headers object before pushStream() changes the pushed :authority in Bun and not in node. The numbers are in this comment.

@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 — a two-line change that makes pushStream()'s :authority default match node's core.js line for line, with tests for both new cases.

What was reviewed:

  • Compared the new condition against node v26.3.0 core.js (getAuthority(headers) === undefined → getAuthority(this[kHeaders])); the Bun getAuthority helper at src/js/node/http2.ts:456 has the same :authority-then-host semantics.
  • The "no :authority on a push that carries host" case (RFC 9113 8.4.1) is node parity, and the base branch previously sent both :authority and host there — this is not a regression.
  • Existing pushedHeaderBlocks callers pass at most two arguments, so the new defaulted options object leaves their behavior unchanged; the two new tests wire every error/close path to reject and assert whole header objects with toEqual.
Extended reasoning...

Overview

The PR touches two files. In src/js/node/http2.ts the :authority default in ServerHttp2Stream.pushStream (around line 3247) changes from reading :authority alone on both the push headers and the parent request headers to routing both through the existing getAuthority() helper, which returns :authority if defined, else host. A short comment cites the node source line being mirrored. In test/js/node/http2/node-http2.test.js, the pushedHeaderBlocks helper gains a defaulted third options argument (requestHeaders, pseudoHeaders), a new pushedAuthority helper captures both the server's push.sentHeaders and the client-received PUSH_PROMISE headers, and two tests cover the two new behaviors (request with only host supplies the default; push with host gets no :authority added).

Security risks

None identified. The change only affects which header value is copied into an outbound PUSH_PROMISE header block on the server side; values still pass through the existing header validation and native serialization (pushPromise). It does not touch TLS, auth, or input parsing, and reduces rather than increases malformed frames (a PUSH_PROMISE with neither :authority nor host is rejected by nghttp2 and by Bun's own inbound engine).

Level of scrutiny

Low-to-moderate. The logic is two lines and is a direct port of node's core.js (if (getAuthority(headers) === undefined) headers[HTTP2_HEADER_AUTHORITY] = getAuthority(this[kHeaders])). I confirmed Bun's getAuthority at line 456 matches node's semantics. The one edge case worth thinking about — the parent request having neither :authority nor host, leaving an own-property :authority: undefined — is unchanged from the base branch (the old code also assigned parentRequestHeaders[':authority'] unconditionally) and node behaves the same way; the native header walk skips undefined values. The single candidate issue investigated (dropping :authority from a push that already carries host) is deliberate node parity, and the base branch previously sent both fields, which was the actual divergence from node.

Other factors

The test helper's signature change is backward compatible: I grepped every existing pushedHeaderBlocks( call site and none passes a third argument, so their captured blocks are unchanged. The new tests follow the file's conventions (Promise.withResolvers, every error/callback failure wired to reject, toEqual on whole header objects, no timeouts or sleeps). The bug hunt exited on dry_streak with no findings, no CODEOWNERS entry covers src/js/node/http2.ts or the test file, and there are no third-party review objections in the timeline. The change is small, self-contained, and behavior-verified against node, so a human look is not required.

@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

One correction to the review above. The change does not match node line for line. The difference is the time at which the request side is read.

  • Node stores getAuthority(headers) when it creates the stream (core.js#L2887). pushStream() uses that stored value.
  • Bun reads the stored request headers object at push time. That object is the one the 'stream' listener receives, so the listener can change it.

I ran a listener that changes the object before it calls pushStream({ ":path": "/pushed" }):

the listener sets node v26.3.0 pushes bun 1.4.3 pushes this branch pushes
headers.host = "mutated.test" (request had host: example.test) :authority: example.test no authority :authority: mutated.test
headers[":authority"] = "mutated.test" (default request) :authority: 127.0.0.1:<port> :authority: mutated.test :authority: mutated.test

The second row shows that the late read is already in bun 1.4.3. This PR extends the same read to host, and it does not fix the late read. A fix stores the value in the streamHeaders handler, next to the lines that #43206 rewrites, so I kept it out of this PR. The PR body now says this in the Fix section and under "Not changed here". No issue tracks it yet.

Comment thread src/js/node/http2.ts Outdated
Keep the node permalink. The helper name and the comment a few lines
above already say the rest.

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

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.

1 participant