diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 8c76eef28726..a7b83e654f82 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -434,6 +434,7 @@ declare function $ERR_HTTP2_STREAM_ERROR(code): Error; declare function $ERR_HTTP2_SESSION_ERROR(code): Error; declare function $ERR_HTTP2_PAYLOAD_FORBIDDEN(status): Error; declare function $ERR_HTTP2_INVALID_INFO_STATUS(code): RangeError; +declare function $ERR_HTTP2_UNSUPPORTED_PROTOCOL(protocol: string): Error; declare function $ERR_INVALID_URL(input, base?): TypeError; declare function $ERR_INVALID_CHAR(name, field?): TypeError; declare function $ERR_HTTP_INVALID_HEADER_VALUE(value: string, name: string): TypeError; diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 911d782f8bd3..1a1bc5c1154e 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -80,6 +80,7 @@ const StringPrototypeTrim = String.prototype.trim; const ArrayPrototypePush = Array.prototype.push; const StringPrototypeToLowerCase = String.prototype.toLowerCase; const StringPrototypeIncludes = String.prototype.includes; +const StringPrototypeSlice = String.prototype.slice; const StringPrototypeStartsWith = String.prototype.startsWith; const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; @@ -3657,10 +3658,14 @@ class ServerHttp2Stream extends Http2Stream { } function connectWithProtocol(protocol: string, options: Http2ConnectOptions | string | URL, listener?: Function) { - if (protocol === "http:") { - return net.connect(options, listener); + switch (protocol) { + case "http:": + return net.connect(options, listener); + case "https:": + return tls.connect(options, listener); + default: + throw $ERR_HTTP2_UNSUPPORTED_PROTOCOL(protocol); } - return tls.connect(options, listener); } function emitConnectNT(self, socket) { @@ -4932,7 +4937,7 @@ class ClientHttp2Session extends Http2Session { #socket_proxy: Proxy; #parser: typeof H2FrameParser | null; - #url: URL; + #defaultScheme: string; #authority: string; #alpnProtocol: string | undefined = undefined; #localSettings: Settings | null = null; @@ -5613,16 +5618,10 @@ class ClientHttp2Session extends Http2Session { this.#strictFieldWhitespaceValidation = false; } this[kStrictSingleValueFields] = options.strictSingleValueFields; - this.#url = url; const protocol = url.protocol || options?.protocol || "https:"; - switch (protocol) { - case "http:": - case "https:": - break; - default: - throw $ERR_HTTP2_UNSUPPORTED_PROTOCOL(protocol); - } + // Like node's session[kProtocol].slice(0, -1): https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/util.js#L667 + this.#defaultScheme = StringPrototypeSlice.$call(protocol, 0, -1); const port = url.port ? parseInt(url.port, 10) : protocol === "http:" ? 80 : 443; let host = "localhost"; @@ -5970,8 +5969,7 @@ class ClientHttp2Session extends Http2Session { additionalPseudoHeaders.push(HTTP2_HEADER_AUTHORITY, authority); } if (scheme === undefined) { - const urlProtocol: string = this.#url?.protocol || options?.protocol || "https:"; - scheme = urlProtocol === "http:" ? "http" : urlProtocol === "https:" ? "https" : urlProtocol; + scheme = this.#defaultScheme; additionalPseudoHeaders.push(HTTP2_HEADER_SCHEME, scheme); } if (path === undefined) { @@ -6057,7 +6055,6 @@ class ClientHttp2Session extends Http2Session { // node keeps the never-index list visible on the request's sentHeaders (symbol keys are // not iterated by the wire-encoding path, so re-attaching is safe). if (sensitives !== undefined) headers[sensitiveHeaders] = sensitives; - const url = this.#url; // RFC 9113 §8.5: CONNECT must carry an explicit :authority and no :scheme/:path — validated // before any defaults are applied. @@ -6090,20 +6087,8 @@ class ClientHttp2Session extends Http2Session { } if (method !== HTTP2_METHOD_CONNECT || headers[":protocol"] !== undefined) { - let scheme = headers[":scheme"]; - if (!scheme) { - let protocol: string = url.protocol || options?.protocol || "https:"; - switch (protocol) { - case "https:": - scheme = "https"; - break; - case "http:": - scheme = "http"; - break; - default: - scheme = protocol; - } - headers[":scheme"] = scheme; + if (!headers[":scheme"]) { + headers[":scheme"] = this.#defaultScheme; } if (headers[":path"] == undefined) { diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index d3afabf7d224..8fac72139b0e 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -1910,6 +1910,7 @@ static constexpr SimpleErrorMessage simpleErrorMessages[] = { { ErrorCode::ERR_HTTP2_SESSION_ERROR, 1, { "Session closed with error code "_s, ""_s, ""_s } }, { ErrorCode::ERR_HTTP2_PAYLOAD_FORBIDDEN, 1, { "Responses with "_s, " status must not have a payload"_s, ""_s } }, { ErrorCode::ERR_HTTP2_INVALID_INFO_STATUS, 1, { "Invalid informational status code: "_s, ""_s, ""_s } }, + { ErrorCode::ERR_HTTP2_UNSUPPORTED_PROTOCOL, 1, { "protocol \""_s, "\" is unsupported."_s, ""_s } }, { ErrorCode::ERR_HTTP_INVALID_HEADER_VALUE, 2, { "Invalid value \""_s, "\" for header \""_s, "\""_s } }, { ErrorCode::ERR_HTTP_HEADERS_SENT, 1, { "Cannot "_s, " headers after they are sent to the client"_s, ""_s } }, { ErrorCode::ERR_UNESCAPED_CHARACTERS, 1, { ""_s, " contains unescaped characters"_s, ""_s } }, diff --git a/test/js/node/http2/node-http2-connect-protocol.test.ts b/test/js/node/http2/node-http2-connect-protocol.test.ts new file mode 100644 index 000000000000..3804cd8e0125 --- /dev/null +++ b/test/js/node/http2/node-http2-connect-protocol.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, mock, test } from "bun:test"; +import http2 from "node:http2"; +import type { AddressInfo } from "node:net"; +import { duplexPair } from "node:stream"; + +// node accepts more than @types/node declares: a plain-object authority, and options it ignores. +const connect = http2.connect as (...args: any[]) => http2.ClientHttp2Session; + +type PseudoHeaders = { scheme: string; authority: string }; + +// An http2 server that reports the :scheme and :authority of each request it receives. +function echoServer() { + const server = http2.createServer(); + server.on("stream", (stream: http2.ServerHttp2Stream, headers: http2.IncomingHttpHeaders) => { + stream.respond({ ":status": 200 }); + stream.end(JSON.stringify({ scheme: headers[":scheme"], authority: headers[":authority"] })); + }); + return server; +} + +// The same server, answering on one side of an in-memory duplex pair. +function echoServerOverDuplexPair() { + const [clientSide, serverSide] = duplexPair(); + const server = echoServer(); + server.emit("connection", serverSide); + return { server, clientSide }; +} + +function requestJSON( + client: http2.ClientHttp2Session, + headers: http2.OutgoingHttpHeaders | string[], + requestOptions?: object, +): Promise { + return new Promise((resolve, reject) => { + const req = client.request(headers, requestOptions); + let body = ""; + req.setEncoding("utf8"); + req.on("data", chunk => (body += chunk)); + req.on("error", reject); + req.on("end", () => resolve(body)); + req.end(); + }).then(JSON.parse); +} + +// One request with an object of headers, one with a raw [name, value, ...] array. +function pseudoHeadersSeenByServer(client: http2.ClientHttp2Session, requestOptions?: object) { + const failed = new Promise((_, reject) => client.on("error", reject)); + const seen = Promise.all([ + requestJSON(client, { ":path": "/" }, requestOptions), + requestJSON(client, [":path", "/"], requestOptions), + ]); + return Promise.race([seen, failed]); +} + +describe("http2.connect() authority protocol", () => { + test("throws ERR_HTTP2_UNSUPPORTED_PROTOCOL with Node's message", () => { + function thrownBy(...args: unknown[]) { + let session: http2.ClientHttp2Session; + try { + session = connect(...args); + } catch (e: any) { + return { name: e.name, code: e.code, message: e.message }; + } + session.on("error", () => {}); + session.destroy(); + return "no throw"; + } + const unsupported = (protocol: string) => ({ + name: "Error", + code: "ERR_HTTP2_UNSUPPORTED_PROTOCOL", + message: `protocol "${protocol}" is unsupported.`, + }); + + expect(thrownBy("ftp://127.0.0.1:1")).toEqual(unsupported("ftp:")); + expect(thrownBy(new URL("ssh://localhost"))).toEqual(unsupported("ssh:")); + expect(thrownBy({ protocol: "gopher:", hostname: "localhost", port: 1 })).toEqual(unsupported("gopher:")); + expect(thrownBy({ port: 1 }, { protocol: "ws:" })).toEqual(unsupported("ws:")); + // Not a function, so connect() still opens the socket itself. + expect(thrownBy("ftp://127.0.0.1:1", { createConnection: true })).toEqual(unsupported("ftp:")); + }); + + // node only reaches its protocol switch when it opens the socket itself: + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js#L3629-L3643 + test("is not checked when options.createConnection opens the socket", async () => { + const { server, clientSide } = echoServerOverDuplexPair(); + const createConnection = mock((_authority: URL) => clientSide); + let client: http2.ClientHttp2Session | undefined; + try { + client = connect("ftp://example.test", { createConnection }); + expect(createConnection).toHaveBeenCalledTimes(1); + expect(createConnection.mock.calls[0][0].protocol).toBe("ftp:"); + expect(await pseudoHeadersSeenByServer(client)).toEqual([ + { scheme: "ftp", authority: "example.test:443" }, + { scheme: "ftp", authority: "example.test:443" }, + ]); + } finally { + client?.close(); + server.close(); + } + }); + + test("from options.protocol is the default :scheme of a request", async () => { + const server = echoServer(); + let client: http2.ClientHttp2Session | undefined; + try { + const port = await new Promise(resolve => + server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port)), + ); + client = connect({ hostname: "127.0.0.1", port }, { protocol: "http:" }); + expect(await pseudoHeadersSeenByServer(client)).toEqual([ + { scheme: "http", authority: `127.0.0.1:${port}` }, + { scheme: "http", authority: `127.0.0.1:${port}` }, + ]); + } finally { + client?.close(); + server.close(); + } + }); + + // node has no `protocol` request option: only the connect-time protocol names the scheme. + test("is not overridden by a protocol in the request() options", async () => { + const { server, clientSide } = echoServerOverDuplexPair(); + let client: http2.ClientHttp2Session | undefined; + try { + client = connect({ hostname: "example.test", port: 443 }, { createConnection: () => clientSide }); + expect(await pseudoHeadersSeenByServer(client, { protocol: "http:" })).toEqual([ + { scheme: "https", authority: "example.test:443" }, + { scheme: "https", authority: "example.test:443" }, + ]); + } finally { + client?.close(); + server.close(); + } + }); +});