From 86e9260df84bad5c9c4ceb91e407b77744265db9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:16:13 +0000 Subject: [PATCH 1/2] Bump WebKit to the preview build of oven-sh/WebKit#909 The preview is main's pin fcc1ea0973a4 plus two commits in URLParser::parseIPv6Host: a compressed IPv6 address takes an IPv4 tail at piece 5, and an address that ends with a single ':' does not parse. The merged commit replaces the preview tag before this leaves draft. --- scripts/build/deps/webkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index ee650aac2abb..169fe91711e2 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "fcc1ea0973a4b96a007e699b25f52aab6a3d3470"; +export const WEBKIT_VERSION = "autobuild-preview-pr-909-c061b103"; /** * WebKit (JavaScriptCore) — the JS engine, with WTF and bmalloc. From c035c8a057e0db92cb8e9f1b1a38fa3e3106fe3b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:16:15 +0000 Subject: [PATCH 2/2] test: IPv6 hosts follow the IPv6 parser of the URL Standard An IPv6 host that ends with a single colon does not parse. A compressed IPv6 host with an IPv4 address at the end parses. new URL, URL.parse, URL.canParse, the href, host and hostname setters, Request, WebSocket, fetch, URLPattern inputs, url.domainToASCII and url.domainToUnicode all reach the same host parser. Every expected value is also what Node.js v26.3.0 gives. The url.domainToUnicode loop over the invalid inputs called url.domainToASCII. It calls url.domainToUnicode now. --- .../node/url/url-domain-ascii-unicode.test.js | 4 +- test/js/web/url/url.test.ts | 84 +++++++++++++++++++ test/js/web/urlpattern/urlpattern.test.ts | 23 +++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/test/js/node/url/url-domain-ascii-unicode.test.js b/test/js/node/url/url-domain-ascii-unicode.test.js index 8f9ab3cc094d..0e51a6940ec9 100644 --- a/test/js/node/url/url-domain-ascii-unicode.test.js +++ b/test/js/node/url/url-domain-ascii-unicode.test.js @@ -72,6 +72,8 @@ const invalids = [ [null, "null"], [undefined, "undefined"], ["2001:0db8:85a3:0000:0000:8a2e:0370:7334", ""], + // An IPv6 literal cannot end with a single ":". + ["[::1:]", ""], ]; describe("url.domainToASCII", () => { @@ -97,7 +99,7 @@ describe("url.domainToUnicode", () => { } for (const [input, expected] of invalids) { test(`-> '${input}' is '${expected}'`, () => { - expect(url.domainToASCII(input)).toEqual(expected); + expect(url.domainToUnicode(input)).toEqual(expected); }); } }); diff --git a/test/js/web/url/url.test.ts b/test/js/web/url/url.test.ts index b43eeb3af4b0..390f2b806eea 100644 --- a/test/js/web/url/url.test.ts +++ b/test/js/web/url/url.test.ts @@ -140,6 +140,90 @@ describe("url", () => { expect(hn.hostname).toBe("xn--s5a.com"); }); + // URL Standard, IPv6 parser: after a piece and a ":", the end of the address is a failure. + it("rejects an IPv6 host that ends with a single colon (URL Standard IPv6 parser)", async () => { + for (const input of [ + "http://[::1:]/", + "http://[::1:]", + "http://[1::2:]/", + "http://[::1:]:81/", + "http://[1:2::3:4:]/path?q#f", + "http://[::A:\t]/", + "http://[::a\n:]/", + "ws://[::1:]/", + "file://[::1:]/x", + "foo://[::1:]/", + ]) { + expect([input, URL.canParse(input)]).toEqual([input, false]); + expect([input, URL.parse(input)]).toEqual([input, null]); + expect(() => new URL(input)).toThrow(TypeError); + } + expect(() => new URL("/x", "http://[::1:]/")).toThrow(TypeError); + expect(() => new URL("//[::1:]/x", "http://example.com/")).toThrow(TypeError); + expect(() => new Request("http://[::1:]/")).toThrow(TypeError); + expect(() => new WebSocket("ws://[::1:]/")).toThrow(expect.objectContaining({ name: "SyntaxError" })); + expect(await fetch("http://[::1:]:1/").catch(e => e)).toBeInstanceOf(TypeError); + + // A "::" at the end is a compression, so these stay valid. + for (const [input, href] of [ + ["http://[1::]/", "http://[1::]/"], + ["http://[::]/", "http://[::]/"], + ["http://[1:2:3:4:5:6:7::]/", "http://[1:2:3:4:5:6:7:0]/"], + ["http://[::1]:81/", "http://[::1]:81/"], + ]) { + expect([input, new URL(input).href]).toEqual([input, href]); + } + + // The setters use the same host parser. A value that it rejects leaves the URL unchanged. + const before = "http://example.com:8080/p"; + const set = (key: "host" | "hostname", value: string) => { + const url = new URL(before); + url[key] = value; + return url.href; + }; + expect({ + "host = [::1:]": set("host", "[::1:]"), + "host = [1::2:]": set("host", "[1::2:]"), + "host = [::1:]:81": set("host", "[::1:]:81"), + "hostname = [::1:]": set("hostname", "[::1:]"), + "hostname = [1::2:]": set("hostname", "[1::2:]"), + "host = [::1]": set("host", "[::1]"), + "host = [::1]:81": set("host", "[::1]:81"), + "hostname = [1::]": set("hostname", "[1::]"), + }).toEqual({ + "host = [::1:]": before, + "host = [1::2:]": before, + "host = [::1:]:81": before, + "hostname = [::1:]": before, + "hostname = [1::2:]": before, + "host = [::1]": "http://[::1]:8080/p", + "host = [::1]:81": "http://[::1]:81/p", + "hostname = [1::]": "http://[1::]:8080/p", + }); + const url = new URL(before); + expect(() => (url.href = "http://[::1:]/")).toThrow(TypeError); + expect(url.href).toBe(before); + }); + + // URL Standard, IPv6 parser: an IPv4 address takes the last two pieces, so it can start at piece 6 or before. + it("parses a compressed IPv6 host that ends with an IPv4 address (URL Standard IPv6 parser)", () => { + for (const [input, href] of [ + ["http://[2001:db8:1234::5efe:127.0.0.1]/", "http://[2001:db8:1234::5efe:7f00:1]/"], + ["http://[a:b:c:d::127.0.0.1]/", "http://[a:b:c:d::7f00:1]/"], + ["http://[::1:2:3:4:1.2.3.4]/", "http://[::1:2:3:4:102:304]/"], + ["http://[1:2:3:4:5::1.2.3.4]/", "http://[1:2:3:4:5:0:102:304]/"], + ["http://[::1.2.3.4]/", "http://[::102:304]/"], + ]) { + expect([input, new URL(input).href]).toEqual([input, href]); + } + for (const input of ["http://[2001:db8:1234:4:5::5efe:127.0.0.1]/", "http://[1:2:3:4:5:6::1.2.3.4]/"]) { + expect([input, URL.canParse(input)]).toEqual([input, false]); + } + const url = new URL("http://example.com/"); + url.hostname = "[2001:db8:1234::5efe:10.0.0.1]"; + expect(url.href).toBe("http://[2001:db8:1234::5efe:a00:1]/"); + }); + it("rejects invalid punycode labels however they are spelled in the input (like Node)", () => { for (const input of [ "https://xn--a.com/", diff --git a/test/js/web/urlpattern/urlpattern.test.ts b/test/js/web/urlpattern/urlpattern.test.ts index cd6052e003a9..31af7b81beec 100644 --- a/test/js/web/urlpattern/urlpattern.test.ts +++ b/test/js/web/urlpattern/urlpattern.test.ts @@ -209,6 +209,29 @@ describe("URLPattern", () => { }); }); + // A URL input and a hostname input go through the URL host parser before they are matched. + test("an IPv6 hostname input that ends with a single colon does not match", () => { + const loopback = new URLPattern({ hostname: "[\\:\\:1]" }); + const any = new URLPattern({ hostname: "*" }); + expect({ + url: loopback.test("http://[::1]/"), + hostname: loopback.test({ hostname: "[::1]" }), + urlWithColon: loopback.test("http://[::1:]/"), + hostnameWithColon: loopback.test({ hostname: "[::1:]" }), + baseWithColon: loopback.test("/", "http://[::1:]/"), + anyUrlWithColon: any.exec("http://[::1:]/"), + anyHostnameWithColon: any.exec({ hostname: "[::1:]" }), + }).toEqual({ + url: true, + hostname: true, + urlWithColon: false, + hostnameWithColon: false, + baseWithColon: false, + anyUrlWithColon: null, + anyHostnameWithColon: null, + }); + }); + // The tokenizer emits one token per code point, and the synthetic allocation limit caps the token list. test("throws for a pattern with more tokens than the token list allows", () => { const dots = (length: number) => Buffer.alloc(length, ".").toString();