Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/jsc/bindings/NodeURL.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
118 changes: 117 additions & 1 deletion test/js/node/url/url-domain-ascii-unicode.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Comment thread
claude[bot] marked this conversation as resolved.
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"]);
});
});
32 changes: 32 additions & 0 deletions test/js/web/url/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down
Loading