From dc26a49f14fd036854df34383c2a7d46a4972e62 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:30:34 +0000 Subject: [PATCH 1/3] node:http2: match node for invalid origins in session.altsvc() and session.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. --- src/js/node/http2.ts | 16 +++-- test/js/node/http2/node-http2.test.js | 90 +++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 9c7747d20737..ac1043160492 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -3927,17 +3927,15 @@ function getOrigin(origin: any, isAltSvc: boolean): string { try { origin = new URL(origin).origin; } catch (e) { - if (isAltSvc) { - throw $ERR_HTTP2_ALTSVC_INVALID_ORIGIN(); - } else { - throw $ERR_INVALID_URL(origin); - } + throw $ERR_INVALID_URL(origin); } } else if (origin != null && typeof origin === "object") { origin = origin.origin; } validateString(origin, "origin"); - if (!origin || origin === "null") { + // Only "null" is rejected. origin() sends an empty string as a zero-length entry. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js#L1779-L1786 + if (origin === "null") { if (isAltSvc) { throw $ERR_HTTP2_ALTSVC_INVALID_ORIGIN(); } else { @@ -4409,6 +4407,12 @@ class ServerHttp2Session extends Http2Session { if (origin.length + alt.length > MAX_LENGTH) { throw $ERR_HTTP2_ALTSVC_LENGTH(); } + // altsvc(alt) names neither an origin nor a stream. RFC 7838 section 4 makes that frame + // invalid. Node gives no error to match: it aborts the process on a CHECK. + // https://github.com/nodejs/node/blob/v26.3.0/src/node_http2.cc#L3227-L3229 + if (stream === 0 && origin === "") { + throw $ERR_HTTP2_ALTSVC_INVALID_ORIGIN(); + } parser.altsvc(origin, alt, stream); } origin(...origins) { diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 7cf5c3e7ab3a..488d5c5a3e0b 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -3136,6 +3136,80 @@ it("http2 session.altsvc() sends an object origin unchanged, like Node", async ( ]); }); +it("http2 session.altsvc() and session.origin() reject an origin with the same error as Node", async () => { + // A string origin goes through URL parsing first, and its ERR_INVALID_URL is + // not replaced. After that only the origin "null" is invalid. The outcomes + // are those of node v26.3.0, except for the last two calls. + const { promise, resolve, reject } = Promise.withResolvers(); + const alt = 'h2=":8000"'; + const calls = { + 'altsvc(alt, "")': session => session.altsvc(alt, ""), + 'altsvc(alt, "not a url")': session => session.altsvc(alt, "not a url"), + 'altsvc(alt, "null")': session => session.altsvc(alt, "null"), + 'altsvc(alt, "1")': session => session.altsvc(alt, "1"), + 'altsvc(alt, "file:///x")': session => session.altsvc(alt, "file:///x"), + 'origin("")': session => session.origin(""), + 'origin("not a url")': session => session.origin("not a url"), + 'origin("file:///x")': session => session.origin("file:///x"), + 'origin({ origin: "null" })': session => session.origin({ origin: "null" }), + "origin({ origin: 1 })": session => session.origin({ origin: 1 }), + // An empty origin is not "null": the entry is sent with a length of zero. + 'origin({ origin: "" })': session => session.origin({ origin: "" }), + // Neither an origin nor a stream: RFC 7838 section 4 makes that frame + // invalid. Node aborts the process here (a CHECK in Http2Session::AltSvc), + // so there is no error to match. + "altsvc(alt)": session => session.altsvc(alt), + "altsvc(alt, undefined)": session => session.altsvc(alt, undefined), + }; + const outcomes = {}; + + const server = http2.createServer(); + server.on("session", session => { + for (const [name, call] of Object.entries(calls)) { + try { + call(session); + outcomes[name] = "sent"; + } catch (err) { + outcomes[name] = `${err.name} ${err.code}`; + } + } + }); + server.on("stream", stream => { + stream.respond({ ":status": 200 }); + stream.end("ok"); + }); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const client = http2.connect(`http://127.0.0.1:${server.address().port}`); + client.on("error", reject); + const req = client.request({ ":path": "/" }); + req.resume(); + req.on("close", () => { + client.close(); + server.close(); + resolve(); + }); + req.end(); + }); + + await promise; + expect(outcomes).toEqual({ + 'altsvc(alt, "")': "TypeError ERR_INVALID_URL", + 'altsvc(alt, "not a url")': "TypeError ERR_INVALID_URL", + 'altsvc(alt, "null")': "TypeError ERR_INVALID_URL", + 'altsvc(alt, "1")': "TypeError ERR_INVALID_URL", + 'altsvc(alt, "file:///x")': "TypeError ERR_HTTP2_ALTSVC_INVALID_ORIGIN", + 'origin("")': "TypeError ERR_INVALID_URL", + 'origin("not a url")': "TypeError ERR_INVALID_URL", + 'origin("file:///x")': "TypeError ERR_HTTP2_INVALID_ORIGIN", + 'origin({ origin: "null" })': "TypeError ERR_HTTP2_INVALID_ORIGIN", + "origin({ origin: 1 })": "TypeError ERR_INVALID_ARG_TYPE", + 'origin({ origin: "" })': "sent", + "altsvc(alt)": "TypeError ERR_HTTP2_ALTSVC_INVALID_ORIGIN", + "altsvc(alt, undefined)": "TypeError ERR_HTTP2_ALTSVC_INVALID_ORIGIN", + }); +}); + it("http2 client.request() propagates a throwing header-value toString() instead of masking it", async () => { // Node calls `${value}` and lets the user's exception escape; it must not be // replaced with ERR_HTTP2_INVALID_HEADER_VALUE. @@ -3739,6 +3813,22 @@ describe("http2 ALTSVC and ORIGIN frame strings are latin-1", () => { } }); + it("server writes an empty object origin as a zero-length ORIGIN entry", async () => { + const server = http2.createServer(); + server.on("session", session => { + session.origin({ origin: "" }); + session.origin({ origin: "" }, "https://b.example"); + session.origin("https://a.example", { origin: "" }); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + try { + const frames = await framesBeforePingAck(server.address().port, ORIGIN); + expect(frames.map(originEntries)).toEqual([[""], ["", "https://b.example"], ["https://a.example", ""]]); + } finally { + server.close(); + } + }); + it("client reads the ALTSVC origin and value as latin-1", async () => { const server = net.createServer( rawServerConnection([ From 5e0a5f042e6b8367efee354a3e475af38a589384 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:59:09 +0000 Subject: [PATCH 2/3] ci: retrigger From 31b6c9848b4dddfbd5ab76a5229085408ea7c2b8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:03:22 +0000 Subject: [PATCH 3/3] node:http2: shorten two comments in altsvc() and getOrigin() --- src/js/node/http2.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index ac1043160492..8bee414fb859 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -3933,8 +3933,7 @@ function getOrigin(origin: any, isAltSvc: boolean): string { origin = origin.origin; } validateString(origin, "origin"); - // Only "null" is rejected. origin() sends an empty string as a zero-length entry. - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js#L1779-L1786 + // Only "null", not "": https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js#L1784-L1786 if (origin === "null") { if (isAltSvc) { throw $ERR_HTTP2_ALTSVC_INVALID_ORIGIN(); @@ -4407,9 +4406,7 @@ class ServerHttp2Session extends Http2Session { if (origin.length + alt.length > MAX_LENGTH) { throw $ERR_HTTP2_ALTSVC_LENGTH(); } - // altsvc(alt) names neither an origin nor a stream. RFC 7838 section 4 makes that frame - // invalid. Node gives no error to match: it aborts the process on a CHECK. - // https://github.com/nodejs/node/blob/v26.3.0/src/node_http2.cc#L3227-L3229 + // Node aborts here (CHECK): https://github.com/nodejs/node/blob/v26.3.0/src/node_http2.cc#L3227-L3229 if (stream === 0 && origin === "") { throw $ERR_HTTP2_ALTSVC_INVALID_ORIGIN(); }