From fe8eaaf1c90edc01b751da3727edfbd0e65c1a3f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:47:28 +0000 Subject: [PATCH 1/3] url: preprocess host/hostname setter values for IDNA The URL host/hostname setter must run the basic URL parser with state override, which strips ASCII tab/newline and percent-decodes before domain-to-ASCII. WTF::URL::setHost instead runs uidna_nameToASCII on the raw value whenever it contains non-ASCII, so a tab or a percent-escape is baked into the Punycode A-label and the setter disagrees with the constructor on the same input. Strip ASCII tab/newline and UTF-8 percent-encode non-ASCII in URLDecomposition before calling WTF::URL::setHost so it takes its ASCII passthrough and the full URLParser runs on reparse. --- src/jsc/bindings/URLDecomposition.cpp | 51 ++++++++++++++++++++++++ test/js/web/url/url.test.ts | 56 +++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/src/jsc/bindings/URLDecomposition.cpp b/src/jsc/bindings/URLDecomposition.cpp index 200530bb9ee..d2f0e8bc0a8 100644 --- a/src/jsc/bindings/URLDecomposition.cpp +++ b/src/jsc/bindings/URLDecomposition.cpp @@ -25,6 +25,7 @@ #include "URLDecomposition.h" +#include #include namespace WebCore { @@ -104,8 +105,54 @@ static unsigned countASCIIDigits(StringView string) return length; } +static inline bool isASCIITabOrNewline(char32_t c) +{ + return c == 0x0009 || c == 0x000A || c == 0x000D; +} + +// WTF::URL::setHost IDNA-encodes non-ASCII input without the spec's tab/newline strip or +// percent-decode. Strip tab/newline and percent-encode non-ASCII so it takes the ASCII +// passthrough and the full URLParser (which does both) runs on reparse. +static String preprocessHostSetterValue(StringView value) +{ + bool needsWork = false; + for (auto codePoint : value.codePoints()) { + if (!isASCII(codePoint) || isASCIITabOrNewline(codePoint)) { + needsWork = true; + break; + } + } + if (!needsWork) + return { }; + + StringBuilder builder; + builder.reserveCapacity(value.length()); + for (auto codePoint : value.codePoints()) { + if (isASCIITabOrNewline(codePoint)) + continue; + if (isASCII(codePoint)) { + builder.append(static_cast(codePoint)); + continue; + } + uint8_t utf8[U8_MAX_LENGTH]; + unsigned utf8Length = 0; + // Input is IDLUSVString, so no unpaired surrogates. + U8_APPEND_UNSAFE(utf8, utf8Length, codePoint); + for (unsigned i = 0; i < utf8Length; ++i) { + builder.append('%'); + builder.append(static_cast(upperNibbleToASCIIHexDigit(utf8[i]))); + builder.append(static_cast(lowerNibbleToASCIIHexDigit(utf8[i]))); + } + } + return builder.toString(); +} + void URLDecomposition::setHost(StringView value) { + String preprocessed = preprocessHostSetterValue(value); + if (!preprocessed.isNull()) + value = preprocessed; + auto fullURL = this->fullURL(); if (value.isEmpty() && !fullURL.protocolIsFile() && fullURL.hasSpecialScheme()) return; @@ -147,6 +194,10 @@ String URLDecomposition::hostname() const void URLDecomposition::setHostname(StringView host) { + String preprocessed = preprocessHostSetterValue(host); + if (!preprocessed.isNull()) + host = preprocessed; + auto fullURL = this->fullURL(); if (host.isEmpty() && !fullURL.protocolIsFile() && fullURL.hasSpecialScheme()) return; diff --git a/test/js/web/url/url.test.ts b/test/js/web/url/url.test.ts index 818b1152c45..a4360901b73 100755 --- a/test/js/web/url/url.test.ts +++ b/test/js/web/url/url.test.ts @@ -237,6 +237,62 @@ describe("url", () => { }); }); + // The host/hostname setter runs the basic URL parse with state override: it must strip + // ASCII tab/newline and percent-decode before domain-to-ASCII, exactly like the constructor. + describe("host/hostname setter matches constructor on IDNA input", () => { + const setHostname = (v: string) => { + const u = new URL("http://base9.example/a"); + u.hostname = v; + return u.hostname; + }; + const setHost = (v: string) => { + const u = new URL("http://base9.example/a"); + u.host = v; + return u.host; + }; + const ctor = (v: string) => new URL(`http://${v}/`).hostname; + + it.each([ + // tab/LF/CR in non-ASCII input must be stripped before IDNA, not punycode-encoded + ["\tß.de", "xn--zca.de"], + ["ü\t.de", "xn--tda.de"], + ["ß\n.de", "xn--zca.de"], + ["ß\r.de", "xn--zca.de"], + // pure-ASCII input was already stripped correctly + ["a\tb.com", "ab.com"], + // percent-escapes must be decoded before domain-to-ASCII + ["ü%41.de", "xn--a-dha.de"], + // mixed percent-escape and raw non-ASCII must not be silently dropped + ["%C3%BCü.de", "xn--tdaa.de"], + // astral plane (surrogate pair) code point with tab + ["\t\u{10348}.com", "xn--2c8c.com"], + ])("hostname/host setter with %j", (input, expected) => { + expect({ + ctor: ctor(input), + hostname: setHostname(input), + host: setHost(input), + }).toEqual({ ctor: expected, hostname: expected, host: expected }); + }); + + it("host setter with non-ASCII + port", () => { + const u = new URL("http://base9.example/a"); + u.host = "\tß.de:8080"; + expect({ hostname: u.hostname, port: u.port }).toEqual({ hostname: "xn--zca.de", port: "8080" }); + }); + + it("host setter with non-ASCII + default port", () => { + const u = new URL("http://base9.example/a"); + u.host = "ü%41.de:80"; + expect({ hostname: u.hostname, port: u.port }).toEqual({ hostname: "xn--a-dha.de", port: "" }); + }); + + it("hostname setter with only tab/newline leaves special-scheme host unchanged", () => { + const u = new URL("http://base9.example/a"); + u.hostname = "\t\n\r"; + expect(u.hostname).toBe("base9.example"); + }); + }); + // Web IDL record conversion interleaves Get with value conversion: mutations made by a // value's toString() are observed by the keys that follow it. Node agrees. it("URLSearchParams constructed from an object interleaves Get with value conversion", () => { From fbb8426e3ca5b91ba59a0ff14d5c594f9ca2cb41 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:50:04 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- src/jsc/bindings/URLDecomposition.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jsc/bindings/URLDecomposition.cpp b/src/jsc/bindings/URLDecomposition.cpp index d2f0e8bc0a8..8b050c07fc3 100644 --- a/src/jsc/bindings/URLDecomposition.cpp +++ b/src/jsc/bindings/URLDecomposition.cpp @@ -123,7 +123,7 @@ static String preprocessHostSetterValue(StringView value) } } if (!needsWork) - return { }; + return {}; StringBuilder builder; builder.reserveCapacity(value.length()); From 4e874939837375c8c58e0defb954cc714d563e27 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:09:22 +0000 Subject: [PATCH 3/3] ci: retrigger