diff --git a/src/jsc/bindings/DOMURL.cpp b/src/jsc/bindings/DOMURL.cpp index 64718d73cf3f..924c11245f6f 100644 --- a/src/jsc/bindings/DOMURL.cpp +++ b/src/jsc/bindings/DOMURL.cpp @@ -117,14 +117,7 @@ static String applyIDNADeltaToURLAuthority(const String& urlString, StringView s // The authority ends at the first path/query/fragment terminator; // backslash terminates it for special schemes and never appears in a // valid host, so treating it as a terminator is safe for both kinds. - size_t authorityEnd = view.length(); - for (size_t i = authorityStart; i < view.length(); i++) { - char16_t ch = view[i]; - if (ch == '/' || ch == '?' || ch == '#' || ch == '\\') { - authorityEnd = i; - break; - } - } + size_t authorityEnd = Bun::findURLHostTerminator(view, authorityStart); // Userinfo is percent-encoded, not IDNA-mapped, in node too: only the // host span after the last '@' gets the delta. @@ -150,6 +143,9 @@ static String applyIDNADeltaToURLAuthority(const String& urlString, StringView s return {}; auto mappedHost = Bun::applyUnicode16IDNADelta(hostView.toString()); + // All-ignored host: splicing "" in would reparse http://\u180E/a as http:///a (host "a"); skip so the parser rejects it. + if (mappedHost.isEmpty()) + return {}; StringBuilder builder; builder.append(view.left(hostStart)); builder.append(mappedHost); diff --git a/src/jsc/bindings/NodeURL.cpp b/src/jsc/bindings/NodeURL.cpp index 394c92f88414..d50d5bde64f2 100644 --- a/src/jsc/bindings/NodeURL.cpp +++ b/src/jsc/bindings/NodeURL.cpp @@ -124,6 +124,16 @@ String applyUnicode16IDNADelta(const String& input) return builder.toString(); } +size_t findURLHostTerminator(StringView view, size_t start) +{ + for (size_t i = start; i < view.length(); i++) { + char16_t c = view[i]; + if (c == '/' || c == '\\' || c == '?' || c == '#') + return i; + } + return view.length(); +} + // Port of Node's icu-based ToASCII (removed in nodejs/node#55156): // https://github.com/nodejs/node/blob/9f5000e0f2a2^/src/node_i18n.cc — filter // the CheckHyphens/VerifyDnsLength error classes, fail otherwise unless lenient. @@ -187,14 +197,7 @@ static String parseDomainAsHost(const String& rawDomain) // The hostname setter's basic-URL parse stops at the first path, query, // fragment, or backslash (special scheme) terminator. StringView view { domain }; - size_t end = view.length(); - for (size_t i = 0; i < view.length(); i++) { - char16_t c = view[i]; - if (c == '/' || c == '?' || c == '#' || c == '\\') { - end = i; - break; - } - } + size_t end = findURLHostTerminator(view); String host = domain.left(end); if (host.isEmpty()) return {}; diff --git a/src/jsc/bindings/NodeURLHelpers.h b/src/jsc/bindings/NodeURLHelpers.h index b05875c9449b..c3301407fde9 100644 --- a/src/jsc/bindings/NodeURLHelpers.h +++ b/src/jsc/bindings/NodeURLHelpers.h @@ -20,4 +20,8 @@ bool containsUnicode16IDNADeltaSource(WTF::StringView view); // the input unchanged when no delta source is present. WTF::String applyUnicode16IDNADelta(const WTF::String& input); +// Index of the first WHATWG host-state terminator (/ \ ? #) at or after +// `start`, or view.length() when none. +size_t findURLHostTerminator(WTF::StringView view, size_t start = 0); + } // namespace Bun diff --git a/src/jsc/bindings/URLDecomposition.cpp b/src/jsc/bindings/URLDecomposition.cpp index a0806ad44acf..718c12087df5 100644 --- a/src/jsc/bindings/URLDecomposition.cpp +++ b/src/jsc/bindings/URLDecomposition.cpp @@ -121,11 +121,16 @@ void URLDecomposition::setHost(StringView value) // Non-special schemes and '['-prefixed (IPv6) hosts never run IDNA. String mappedValue; if (fullURL.hasSpecialScheme() && !value.startsWith('[')) { - size_t hostEnd = value.reverseFind(':'); - auto hostSpan = hostEnd == notFound ? value : value.left(hostEnd); + size_t terminator = Bun::findURLHostTerminator(value); + size_t hostEnd = value.left(terminator).reverseFind(':'); + size_t hostSpanEnd = hostEnd == notFound ? terminator : hostEnd; + auto hostSpan = value.left(hostSpanEnd); if (Bun::containsUnicode16IDNADeltaSource(hostSpan)) { auto mappedHost = Bun::applyUnicode16IDNADelta(hostSpan.toString()); - mappedValue = hostEnd == notFound ? mappedHost : makeString(mappedHost, value.substring(hostEnd)); + // A host mapping to empty is a failed host parse, not an assignable literal "". + if (mappedHost.isEmpty()) + return; + mappedValue = makeString(mappedHost, value.substring(hostSpanEnd)); value = mappedValue; } } @@ -173,9 +178,17 @@ void URLDecomposition::setHostname(StringView host) // See setHost: the input is a hostname by definition, and only special // schemes run IDNA on it. String mappedHost; - if (fullURL.hasSpecialScheme() && !host.startsWith('[') && Bun::containsUnicode16IDNADeltaSource(host)) { - mappedHost = Bun::applyUnicode16IDNADelta(host.toString()); - host = mappedHost; + if (fullURL.hasSpecialScheme() && !host.startsWith('[')) { + size_t terminator = Bun::findURLHostTerminator(host); + auto hostSpan = host.left(terminator); + if (Bun::containsUnicode16IDNADeltaSource(hostSpan)) { + auto mappedSpan = Bun::applyUnicode16IDNADelta(hostSpan.toString()); + // See setHost: mapping a non-empty hostname to empty is failure, not "". + if (mappedSpan.isEmpty()) + return; + mappedHost = makeString(mappedSpan, host.substring(terminator)); + host = mappedHost; + } } 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 a148dd49d299..f1570a3f4eb8 100755 --- a/test/js/web/url/url.test.ts +++ b/test/js/web/url/url.test.ts @@ -161,6 +161,64 @@ describe("url", () => { expect(h2.host).toBe("xn--foo-7ka:81"); }); + it("rejects special-scheme hosts made only of IDNA-ignored code points", () => { + // U+180E and U+206A..U+206F map to nothing under UTS #46, so these hosts + // map to the empty string: domain-to-ASCII failure (node throws + // ERR_INVALID_URL), never "promote the first path segment to the host". + const inputs = [ + "http://\u180E/evil.example/x", + "https://\u206A\u206F/other.example/p", + "ws://\u180E/h/p", + "file://\u180E/some/dir/f", + "http://\u180E\u206B:8080/x", + "http://user@\u180E/x", + ]; + const errInvalidURL = expect.objectContaining({ code: "ERR_INVALID_URL" }); + for (const input of inputs) { + expect(() => new URL(input)).toThrow(errInvalidURL); + expect(URL.canParse(input)).toBe(false); + expect(URL.parse(input)).toBeNull(); + const u = new URL("http://ok.example/"); + expect(() => (u.href = input)).toThrow(errInvalidURL); + } + // Scheme-relative input and an all-ignored base reach the same host span. + expect(() => new URL("//\u180E/evil.example/", "http://good.example/")).toThrow(errInvalidURL); + expect(URL.canParse("//\u180E/evil.example/", "http://good.example/")).toBe(false); + expect(URL.parse("//\u180E/evil.example/", "http://good.example/")).toBeNull(); + expect(() => new URL("/x", "http://\u206A/base.example/")).toThrow(errInvalidURL); + expect(URL.canParse("/x", "http://\u206A/base.example/")).toBe(false); + expect(URL.parse("/x", "http://\u206A/base.example/")).toBeNull(); + // Mixed hosts still strip the ignored code point rather than failing. + expect(new URL("http://a\u180Eb/").href).toBe("http://ab/"); + expect(new URL("file://a\u180Eb/x").host).toBe("ab"); + // Setters: a non-empty host that maps to empty is a failed host parse and + // no-ops, even for file: where assigning a literal "" clears the host. + const f1 = new URL("file://server/share"); + f1.host = "\u180E"; + expect(f1.href).toBe("file://server/share"); + const f2 = new URL("file://server/share"); + f2.hostname = "\u180E"; + expect(f2.href).toBe("file://server/share"); + const f3 = new URL("file://server/share"); + f3.host = ""; + expect(f3.href).toBe("file:///share"); + const f4 = new URL("file://server/share"); + f4.hostname = ""; + expect(f4.href).toBe("file:///share"); + // A terminator after the ignored code points must not smuggle the tail + // past the empty-host guard: the host span ends at the first / \ ? #. + for (const tail of ["/x", "\\x", "?x", "#x"]) { + for (const base of ["file://server/share", "http://ok.example/p"]) { + const withHost = new URL(base); + withHost.host = "\u180E" + tail; + expect(withHost.href).toBe(base); + const withHostname = new URL(base); + withHostname.hostname = "\u180E" + tail; + expect(withHostname.href).toBe(base); + } + } + }); + it("prints", () => { // URL.prototype carries [Symbol.for("nodejs.util.inspect.custom")], so // Bun.inspect matches node's util.inspect output.