diff --git a/src/jsc/bindings/NodeURL.cpp b/src/jsc/bindings/NodeURL.cpp index 59331350446a..8a12f7dc5998 100644 --- a/src/jsc/bindings/NodeURL.cpp +++ b/src/jsc/bindings/NodeURL.cpp @@ -112,8 +112,11 @@ bool hasValidPunycodeHost(WTF::StringView host) // Mirrors Node's url.domainToASCII/domainToUnicode, which run the input // through a WHATWG URL host parse (ada's url.set_hostname on a "ws://x" // base). Returns a null String when host parsing fails. -static String parseDomainAsHost(const String& domain) +static String parseDomainAsHost(const String& input) { + // The basic URL parser removes ASCII tab and newline from its input first. + String domain = input.removeCharacters([](char16_t c) { return c == '\t' || c == '\n' || c == '\r'; }); + // The hostname setter's basic-URL parse stops at the first path, query, // fragment, or backslash (special scheme) terminator. StringView view { domain }; 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 912c93637f79..e8dd276e8aed 100644 --- a/test/js/node/url/url-domain-ascii-unicode.test.js +++ b/test/js/node/url/url-domain-ascii-unicode.test.js @@ -96,7 +96,123 @@ 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); }); } }); + +// Node implements both functions as a WHATWG host parse of the input (the hostname setter on a "ws://x" URL) +// and returns "" when that parse fails, so percent-decoding, IPv4/IPv6 canonicalization, tab and newline +// stripping and the authority terminators all apply. Rows are [input, domainToASCII(input), +// domainToUnicode(input)]; the expected values are Node's. +const hostParserParity = [ + // Valid punycode is kept (lowercased); a label that merely contains "xn--" is not punycode. + ["xn--bcher-kva.de", "xn--bcher-kva.de", "b\u00fccher.de"], + ["XN--BCHER-KVA.DE", "xn--bcher-kva.de", "b\u00fccher.de"], + ["xn--fiqs8s", "xn--fiqs8s", "\u4e2d\u56fd"], + ["xn--ls8h.example", "xn--ls8h.example", "\u{1f4a9}.example"], + ["xn--6qqa088eba", "xn--6qqa088eba", "\u4f60\u597d\u4f60\u597d"], + ["axn--a-ecp.example", "axn--a-ecp.example", "axn--a-ecp.example"], + // Forbidden host code points: %, C0 controls, DEL, space. + ["a%b", "", ""], + ["%", "", ""], + ["%ZZ", "", ""], + ["a\x01b", "", ""], + ["a\x0cb", "", ""], + ["a\x7fb", "", ""], + ["a b", "", ""], + [" a ", "", ""], + // Percent-decoding happens before IDNA. + ["%41", "a", "a"], + ["ex%61mple.com", "example.com", "example.com"], + ["%e4%bd%a0%e5%a5%bd", "xn--6qq79v", "\u4f60\u597d"], + ["%zz%66%a", "", ""], + // ASCII lowercasing. + ["EXAMPLE.COM", "example.com", "example.com"], + // IPv4 canonicalization and rejection. + ["0x7f.1", "127.0.0.1", "127.0.0.1"], + ["0x7f.0x0.0x0.0x1", "127.0.0.1", "127.0.0.1"], + ["192.168.1.1", "192.168.1.1", "192.168.1.1"], + ["999.999.999.999", "", ""], + ["1.2.3.4.5", "", ""], + ["09.1", "", ""], + // IPv6. + ["[::1]", "[::1]", "[::1]"], + ["[0:0:0:0:0:0:0:1]", "[::1]", "[::1]"], + ["[::ffff:127.0.0.1]", "[::ffff:7f00:1]", "[::ffff:7f00:1]"], + ["[", "", ""], + ["[:", "", ""], + ["[::1]:80", "", ""], + // Tabs and newlines are removed before anything else: before IDNA runs, so they never end up inside a + // punycode label, and before the IPv6 brackets and the port check are looked at. + ["ex\tample.com", "example.com", "example.com"], + ["a\r\nb", "ab", "ab"], + ["b\t\u00fccher.de", "xn--bcher-kva.de", "b\u00fccher.de"], + ["\u00df\nxn", "xn--xn-fia", "\u00dfxn"], + ["\u03c2a\nxn--bcher-kva", "xn--axn--bcher-kva-phk", "\u03c2axn--bcher-kva"], + ["\t[::1]", "[::1]", "[::1]"], + ["[::1]\n", "[::1]", "[::1]"], + ["\t[::1]\r\n", "[::1]", "[::1]"], + ["[::\n1]", "[::1]", "[::1]"], + ["\t[::1]:80", "", ""], + ["a\t:80", "", ""], + ["\t", "", ""], + // The host ends at the first path, query, fragment or backslash; a port or userinfo fails. + ["a/b", "a", "a"], + ["a?b", "a", "a"], + ["a#b", "a", "a"], + ["a\\b", "a", "a"], + ["a:80", "", ""], + ["a@b", "", ""], + // Valid domains are preserved (CheckHyphens and VerifyDnsLength are off). + ["example.com", "example.com", "example.com"], + ["b\u00fccher.de", "xn--bcher-kva.de", "b\u00fccher.de"], + ["\u00e7.com", "xn--7ca.com", "\u00e7.com"], + ["a..b", "a..b", "a..b"], + [".", ".", "."], + ["..", "..", ".."], + ["example.com.", "example.com.", "example.com."], + ["a_b.example", "a_b.example", "a_b.example"], + ["a-.example", "a-.example", "a-.example"], + ["-a.example", "-a.example", "-a.example"], + ["ab--c.example", "ab--c.example", "ab--c.example"], + ["r4---sn-a5mlrn7s.gevideo.com", "r4---sn-a5mlrn7s.gevideo.com", "r4---sn-a5mlrn7s.gevideo.com"], + // UTS #46 mapping, deviation characters (nontransitional) and CONTEXTJ. A non-ASCII host still goes through + // the full UTS #46 processing, so an xn-- label with a non-ASCII character in it fails. + ["x\u200bn--a.example", "", ""], + ["xn--te\u0161la", "", ""], + ["fa\u00df.de", "xn--fa-hia.de", "fa\u00df.de"], + ["\u0130.com", "xn--i-9bb.com", "i\u0307.com"], + ["\u03c2.com", "xn--3xa.com", "\u03c2.com"], + ["\u30c6\u30b9\u30c8.example", "xn--zckzah.example", "\u30c6\u30b9\u30c8.example"], + ["look\u200cout.net", "", ""], + ["", "", ""], +]; + +describe("WHATWG host parser parity", () => { + for (const [input, ascii, unicode] of hostParserParity) { + test(JSON.stringify(input), () => { + expect({ + ascii: url.domainToASCII(input), + unicode: url.domainToUnicode(input), + }).toEqual({ ascii, unicode }); + }); + } +}); + +// Unicode 16 moved these code points from disallowed to mapped or ignored. ICU 76 is the first release with that +// table; macOS links the system ICU, which can be older. +describe.skipIf(parseInt(process.versions.icu) < 76)("Unicode 16 UTS #46 table", () => { + test("domainToASCII maps the reclassified code points", () => { + expect( + [ + "\u04C0", // CYRILLIC LETTER PALOCHKA + "\u10AC", // GEORGIAN CAPITAL LETTER NAR + "\u2132", // TURNED CAPITAL F + "\u2183", // ROMAN NUMERAL REVERSED ONE HUNDRED + "a\u180Eb", // MONGOLIAN VOWEL SEPARATOR, now ignored + "a\u3164b", // HANGUL FILLER, now ignored + ].map(domain => url.domainToASCII(domain)), + ).toEqual(["xn--s5a", "xn--3kj", "xn--73g", "xn--r5g", "ab", "ab"]); + }); +}); diff --git a/test/js/web/url/url.test.ts b/test/js/web/url/url.test.ts index 3857ed3e9ac0..9a6b3588de76 100755 --- a/test/js/web/url/url.test.ts +++ b/test/js/web/url/url.test.ts @@ -138,6 +138,38 @@ describe("url", () => { const hn = new URL("http://x/"); hn.hostname = "\u04C0.com"; expect(hn.hostname).toBe("xn--s5a.com"); + + // Capitals whose UTS #46 status went from disallowed to mapped, and format + // controls that became ignored. Expected values are Node's. + expect( + [ + "\u10AC", // GEORGIAN CAPITAL LETTER NAR + "a\u10B5", // GEORGIAN CAPITAL LETTER KHAR + "\u2132", // TURNED CAPITAL F + "\u2183", // ROMAN NUMERAL REVERSED ONE HUNDRED + "\uA846\u3002\u2183\u0FB5\uB1AE-", // WPT IdnaTestV2; U+3002 is a label separator + "a\u2061b", // FUNCTION APPLICATION + "a\u3164b", // HANGUL FILLER + ].map(host => new URL(`https://${host}/`).hostname), + ).toEqual(["xn--3kj", "xn--a-hws", "xn--73g", "xn--r5g", "xn--fc9a.xn----qmg097k469k", "ab", "ab"]); + }); + + it("keeps valid xn-- labels, and hosts that only contain the letters xn--", () => { + const accepted = { + "http://xn--bcher-kva.de/": "xn--bcher-kva.de", + "http://XN--BCHER-KVA.DE/": "xn--bcher-kva.de", + "http://xn--fiqs8s/": "xn--fiqs8s", + "http://xn--e1afmkfd.xn--p1ai/": "xn--e1afmkfd.xn--p1ai", + // Not a punycode label: "xn--" is not at the start of it. + "http://axn--a-ecp.example/": "axn--a-ecp.example", + // xn-- outside the host is not looked at. ("xn--a-ecp" is not valid punycode.) + "http://xn--a-ecp@ok.example/": "ok.example", + "http://user:pw@xn--bcher-kva.de:8080/xn--a-ecp?xn--a-ecp#xn--a-ecp": "xn--bcher-kva.de", + // Hosts of non-special schemes are opaque: no IDNA, not even lowercasing. + "foo://xn--a-ecp.example/": "xn--a-ecp.example", + "foo://XN--A-ECP.example/": "XN--A-ECP.example", + }; + expect(Object.fromEntries(Object.keys(accepted).map(input => [input, new URL(input).hostname]))).toEqual(accepted); }); it("rejects invalid punycode labels however they are spelled in the input (like Node)", () => {