Skip to content

node:http2: match node for invalid origins in session.altsvc() and session.origin() - #42397

Open
robobun wants to merge 3 commits into
mainfrom
robobun/238da63b/http2-altsvc-origin-server-args
Open

robobun wants to merge 3 commits into
mainfrom
robobun/238da63b/http2-altsvc-origin-server-args

Conversation

@robobun

@robobun robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Two calls of ServerHttp2Session differ from node v26.3.0. altsvc(alt, origin) with a string that is not a URL ("", "not a url", "null", "1") throws ERR_HTTP2_ALTSVC_INVALID_ORIGIN, node throws ERR_INVALID_URL. origin({ origin: "" }) throws ERR_HTTP2_INVALID_ORIGIN, node sends a zero-length ORIGIN entry because it rejects only "null".
  • altsvc(alt) with no origin and no stream id writes an ALTSVC frame with an empty origin on stream 0. RFC 7838 section 4 makes that frame invalid. A node client answers it with a session error.
  • Cause: getOrigin (src/js/node/http2.ts:3925) replaces the URL error for altsvc and rejects every falsy origin. altsvc() does not check the call without a second argument.

Fix

  • getOrigin rethrows ERR_INVALID_URL for both callers and rejects only "null", like getURLOrigin() and the loop in node's origin().
  • altsvc(alt) with neither an origin nor a stream id throws ERR_HTTP2_ALTSVC_INVALID_ORIGIN, after every other argument check. Node has no error to match here: it aborts the process (CHECK in Http2Session::AltSvc, node_http2.cc:3229). The code is the one node uses for an empty object origin.
  • Verified: two new tests in test/js/node/http2/node-http2.test.js, both fail on 1.4.3-canary. All of node-http2.test.js and the upstream test-http2-altsvc.js and test-http2-origin.js pass.

Background

  • ServerHttp2Session#altsvc(alt, originOrStream) sends an ALTSVC frame (RFC 7838): on stream 0 with an origin, or on a stream without one.
  • ServerHttp2Session#origin(...origins) sends an ORIGIN frame (RFC 8336): a list of length-prefixed origins.
  • For both, a string argument goes through URL parsing and is replaced by its origin. An object's origin property is sent as is.
Notes

Outcomes. Same script on node v26.3.0, bun 1.4.3-canary and this PR. The calls run in the 'session' listener of an h2c server. A raw client reads the ALTSVC and ORIGIN frames that arrive.

call node v26.3.0 bun 1.4.3-canary this PR
altsvc(alt, ""), "not a url", "null", "1" TypeError ERR_INVALID_URL TypeError ERR_HTTP2_ALTSVC_INVALID_ORIGIN TypeError ERR_INVALID_URL
altsvc(alt, "file:///x") ERR_HTTP2_ALTSVC_INVALID_ORIGIN same same
origin({ origin: "" }) sent, payload 0000 ERR_HTTP2_INVALID_ORIGIN sent, payload 0000
origin({ origin: "" }, "https://b.example") sent, 0000 + entry ERR_HTTP2_INVALID_ORIGIN sent, 0000 + entry
origin({ origin: "null" }) ERR_HTTP2_INVALID_ORIGIN same same
altsvc(alt), altsvc(alt, undefined) process abort: Assertion failed: (origin_len != 0 && id == 0) || (origin_len == 0 && id != 0) frame 0000 + alt on stream 0 ERR_HTTP2_ALTSVC_INVALID_ORIGIN

The other 28 rows of the matrix are identical in all three: number arguments (0, 1.5, -1, 2**32, a stream that does not exist), null, true, {}, { origin: 1 }, a non-string alt, an alt with a character above U+00FF, origin() with no arguments, origin(""), origin("not a url"), origin("file:///x").

Why throw for altsvc(alt). The choices are: keep the invalid frame, drop the call in silence, or throw. A client that follows nghttp2 (node, and bun after #42391) destroys the session when it gets that frame. A throw tells the caller what is wrong at the call site. No program that runs on node can depend on the old behavior, because node aborts.

Not matched.

  • Node's ERR_INVALID_URL from getURLOrigin() comes from C++ and has no input property. Bun's has input, as new URL() does in both runtimes. origin() already behaved this way.
  • An object origin with a code unit above U+00FF is written as UTF-8. Node writes the low byte of each code unit. node:http2: write and read ALTSVC and ORIGIN strings as latin-1 #41254 left that open on purpose (header_value_bytes).

…ssion.origin()

- altsvc(alt, string): a string that does not parse as a URL now throws
  ERR_INVALID_URL, like node's getURLOrigin(). It threw
  ERR_HTTP2_ALTSVC_INVALID_ORIGIN.
- origin({ origin: '' }): node rejects only the origin 'null'. An empty
  origin is sent as a zero-length ORIGIN entry. It threw
  ERR_HTTP2_INVALID_ORIGIN.
- altsvc(alt) without an origin and without a stream id wrote an ALTSVC
  frame with an empty origin on stream 0, which RFC 7838 section 4 makes
  invalid. Node aborts the process on a CHECK in Http2Session::AltSvc, so
  there is no error to match. It now throws
  ERR_HTTP2_ALTSVC_INVALID_ORIGIN, the error node uses for an empty
  object origin.
@coderabbitai

coderabbitai Bot commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

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

Or wait 4 minutes for your next included review.

Check out review usage here.

View limit details

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: f8f7c264-6d4f-4bac-9d52-6cb9efaee339

📥 Commits

Reviewing files that changed from the base of the PR and between 471b586 and 31b6c98.

📒 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 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:35 PM PT - Sep 11th, 2026

❌ @robobun, your commit 31b6c98 has 1 failures in Build #114500 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42397

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

bun-42397 --bun

@robobun

robobun commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is green. The red CI jobs are tests that this PR does not touch.

How it was reproduced: an h2c server calls session.altsvc() and session.origin() with each argument in its 'session' listener and records the thrown code. A raw TCP client reads the ALTSVC and ORIGIN frames that arrive. The same script runs on node v26.3.0 and on bun.

USE_SYSTEM_BUN=1 bun test test/js/node/http2/node-http2.test.js -t "reject an origin with the same error as Node|zero-length ORIGIN entry"   # 2 fail
bun bd test test/js/node/http2/node-http2.test.js -t "reject an origin with the same error as Node|zero-length ORIGIN entry"                 # 2 pass

CI: this PR changes src/js/node/http2.ts (argument checks of altsvc() and origin()) and test/js/node/http2/node-http2.test.js. All http2 tests pass on every lane. The failed jobs:

  • Build 114487, windows 11 aarch64: test/js/bun/s3/s3.test.ts, large uploads to R2 time out.
  • Build 114500, debian 13 x64-asan: test/js/bun/http/serve-pending-promise-abort-leak.test.ts, a Bun.serve body stream is still alive after GC.

Both are reported for triage. Neither test uses node:http2.

PR: #42397

@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 — focused Node compat fix with the error contract matched per cited source lines.

What was reviewed:

  • getOrigin() now rethrows ERR_INVALID_URL for both callers and only rejects "null"; checked that altsvc's object-origin branch (line 4395) still rejects "" independently, and that "file:///x" still maps to the "null" → ERR_HTTP2_*_INVALID_ORIGIN path via URL#origin.
  • The new stream === 0 && origin === "" guard is placed after alt/length validation so check ordering matches Node up to the point Node aborts; the divergence is commented with the node_http2.cc CHECK line.
  • Tests: matrix asserts exact name + code per call via one toEqual, wires error to reject, uses port: 0/127.0.0.1; the wire test reuses the existing framesBeforePingAck/originEntries helpers in the same describe block.
Extended reasoning...

Overview

This PR adjusts origin validation in src/js/node/http2.ts for ServerHttp2Session#altsvc() and #origin() to match Node v26.3.0's error contract. Three changes: (1) getOrigin() no longer remaps a URL parse failure to ERR_HTTP2_ALTSVC_INVALID_ORIGIN for altsvc — it always throws ERR_INVALID_URL; (2) getOrigin() no longer rejects a falsy origin, only the literal "null", so origin({origin: ""}) sends a zero-length ORIGIN entry as Node does; (3) altsvc(alt) with neither an origin nor a stream id now throws ERR_HTTP2_ALTSVC_INVALID_ORIGIN instead of writing an RFC-7838-invalid frame (Node aborts on a native CHECK here — the divergence is deliberate and commented with a source permalink). Two tests are added to the existing test/js/node/http2/node-http2.test.js.

Security risks

None. This is error-code alignment for a server-side HTTP/2 API. The one behavioral loosening — allowing origin({origin: ""}) to write a zero-length ORIGIN entry — matches Node exactly and is a server-initiated advisory frame, not input validation on untrusted data. No auth, crypto, or parsing of untrusted bytes is touched.

Level of scrutiny

Low-to-moderate. The source change is ~15 lines, entirely within the JS built-in module, and each decision cites a nodejs/node source line. I verified that the object-origin branch of altsvc() (lines 4387-4397) still independently rejects "" and "null", so removing the !origin check from getOrigin() doesn't regress altsvc(alt, {origin: ""}). The "file:///x" cases still hit ERR_HTTP2_*_INVALID_ORIGIN because new URL("file:///x").origin === "null". The new guard's placement after validateString(alt) / kQuotedString / length checks preserves Node's check ordering for doubly-invalid input up to the abort point.

Other factors

The tests follow repo conventions closely: added to the existing file, port: 0 + 127.0.0.1, Promise.withResolvers(), error events wired to reject, a single expect(outcomes).toEqual({...}) asserting exact err.name + err.code for the full matrix, and a wire-level assertion reusing the file's existing framesBeforePingAck/originEntries helpers inside the same describe block. No CODEOWNERS cover these paths. The bug hunt exited on dry_streak with no findings and no ruled-out candidates.

Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts 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.

LGTM — the follow-up commits tightened the comments to one-liners with the Node permalink kept, and the runtime change itself is unchanged and correct.

What was reviewed:

  • getOrigin paths: string → URL parse throws ERR_INVALID_URL for both callers; only the literal "null" origin is rejected post-parse, so origin({origin: ""}) now passes through — matches Node's core.js loop.
  • altsvc() object-origin branch (lines 4384-4396) still rejects ""/"null", so altsvc(alt, {origin: ""}) still throws ERR_HTTP2_ALTSVC_INVALID_ORIGIN as Node does; the new stream === 0 && origin === "" guard only affects the undefined case where Node aborts on a CHECK.
  • Tests: one .toEqual on the full name+code matrix, Promise.withResolvers, error events wired to reject, port: 0 on 127.0.0.1; the wire test reuses the existing framesBeforePingAck/originEntries helpers verbatim.
Extended reasoning...

Overview

This PR aligns ServerHttp2Session#altsvc() and #origin() invalid-origin error semantics with Node v26.3.0. In src/js/node/http2.ts, getOrigin() now always throws ERR_INVALID_URL when a string origin fails URL parsing (previously the altsvc path substituted ERR_HTTP2_ALTSVC_INVALID_ORIGIN), and the post-parse rejection is narrowed from any falsy origin to only the literal "null". altsvc() gains a guard that throws ERR_HTTP2_ALTSVC_INVALID_ORIGIN when neither an origin nor a stream id is supplied — the case where Node aborts on a CHECK in node_http2.cc. Two tests are added to test/js/node/http2/node-http2.test.js: a full error-code matrix asserted as one object, and an over-the-wire check that {origin: ""} produces zero-length ORIGIN entries.

Security risks

None. This is error-code alignment on a server-side HTTP/2 API. The one behavior loosening — origin({origin: ""}) no longer throws and instead sends a zero-length ORIGIN entry — matches Node exactly and is a valid RFC 8336 frame. The altsvc(alt) case is tightened (throws instead of writing an invalid frame that would cause peer session errors), which is strictly safer than before and safer than Node's process abort.

Level of scrutiny

Moderate, as a node:* compat change. The diff is ~15 lines of runtime code with Node source permalinks on both non-obvious choices, and the deliberate divergence (throw vs. abort) is documented at the guard and in the test. I traced every input class through both getOrigin() and the inline object-origin branch in altsvc(): string origins that parse to "null" (e.g. file:///x) still hit the right per-caller error; altsvc(alt, {origin: ""}) still throws via the unchanged inline check at lines 4394-4396, matching Node's origin.length === 0 check; valid string origins on stream 0 pass the new guard because origin !== "".

Other factors

Since the earlier COMMENTED review, the two follow-up commits only shortened the two source comments to single lines while keeping the Node permalinks — no logic changed. The github-actions inline comments landed on those exact lines and were followed by the shortening commit, so they appear addressed. Tests follow every harness convention called out in REVIEW.md: Promise.withResolvers, error events wired to reject, port: 0 bound to 127.0.0.1, one .toEqual on the whole outcomes object asserting err.name + err.code, and the wire test reuses the existing framesBeforePingAck/originEntries helpers from the same describe block. Exit reason was dry_streak.

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.

2 participants