diff --git a/src/jsc/bindings/URLDecomposition.cpp b/src/jsc/bindings/URLDecomposition.cpp index 200530bb9ee..8b050c07fc3 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", () => {