diff --git a/src/jsc/bindings/BunIDNA.cpp b/src/jsc/bindings/BunIDNA.cpp new file mode 100644 index 000000000000..c98870ebd07c --- /dev/null +++ b/src/jsc/bindings/BunIDNA.cpp @@ -0,0 +1,61 @@ +#include "root.h" +#include "BunIDNA.h" + +#include +#include +#include +#include +#include +#include + +namespace Bun { + +bool domainHasACELabel(WTF::StringView domain) +{ + unsigned labelStart = 0; + while (true) { + if (domain.substring(labelStart).startsWithIgnoringASCIICase("xn--"_s)) + return true; + size_t dot = domain.find('.', labelStart); + if (dot == WTF::notFound) + return false; + labelStart = static_cast(dot) + 1; + } +} + +WTF::String domainToASCII(WTF::StringView domain) +{ + std::array stackBuffer; + WTF::Vector heapBuffer; + std::span buffer { stackBuffer }; + while (true) { + UErrorCode error = U_ZERO_ERROR; + UIDNAInfo processingDetails = UIDNA_INFO_INITIALIZER; + int32_t length = uidna_nameToASCII(&WTF::URLParser::internationalDomainNameTranscoder(), domain.upconvertedCharacters(), domain.length(), buffer.data(), static_cast(buffer.size()), &processingDetails, &error); + if (U_SUCCESS(error) && !(processingDetails.errors & ~WTF::URLParser::allowedNameToASCIIErrors) && length > 0) + return WTF::String { buffer.first(static_cast(length)) }; + // ICU's preflight convention: on overflow, `length` is the required + // size. Retry once so a domain longer than the stack buffer (a host + // the spec places no length limit on) is not treated as invalid. + if (error != U_BUFFER_OVERFLOW_ERROR || length <= 0 || !heapBuffer.isEmpty()) + return {}; + heapBuffer.grow(static_cast(length)); + buffer = heapBuffer.mutableSpan(); + } +} + +bool urlHostIsValidIDNA(const WTF::URL& url) +{ + // Only special-scheme URLs have a domain host; the host of every other + // scheme is opaque and the URL Standard does not apply IDNA to it. + if (!url.hasSpecialScheme()) + return true; + // A parsed special host is already ASCII; UTS-46 "domain to ASCII" can + // only still fail on it when a label uses the Punycode "xn--" prefix. + auto host = url.host(); + if (!domainHasACELabel(host)) + return true; + return !domainToASCII(host).isNull(); +} + +} // namespace Bun diff --git a/src/jsc/bindings/BunIDNA.h b/src/jsc/bindings/BunIDNA.h new file mode 100644 index 000000000000..982e1b3f68b3 --- /dev/null +++ b/src/jsc/bindings/BunIDNA.h @@ -0,0 +1,24 @@ +#pragma once + +#include "root.h" + +#include + +namespace Bun { + +// Whether any dot-separated label of `domain` starts with the Punycode ACE +// prefix "xn--" (ASCII case-insensitive). Those are the only all-ASCII +// labels that UTS-46 "domain to ASCII" can reject. +bool domainHasACELabel(WTF::StringView domain); + +// UTS-46 "domain to ASCII" with the URL Standard's options (beStrict = false): +// https://url.spec.whatwg.org/#concept-domain-to-ascii +// Returns a null String when `domain` is not a valid IDNA domain. +WTF::String domainToASCII(WTF::StringView domain); + +// WTF::URLParser never runs UTS-46 on an all-ASCII host, so an "xn--" label +// that does not decode to a valid IDNA label parses successfully. The URL +// Standard's host parser requires that to fail; re-check such hosts here. +bool urlHostIsValidIDNA(const WTF::URL&); + +} // namespace Bun diff --git a/src/jsc/bindings/DOMURL.cpp b/src/jsc/bindings/DOMURL.cpp index 61af7c63383d..f775d8277268 100644 --- a/src/jsc/bindings/DOMURL.cpp +++ b/src/jsc/bindings/DOMURL.cpp @@ -29,6 +29,7 @@ #include "ActiveDOMObject.h" // #include "Blob.h" // #include "BlobURL.h" +#include "BunIDNA.h" // #include "MemoryCache.h" // #include "PublicURLManager.h" // #include "ResourceRequest.h" @@ -55,6 +56,14 @@ static inline String redact(const String& input) return makeString('"', input, '"'); } +// WTF::URLParser skips UTS-46 validation for all-ASCII hosts, so "xn--" +// (Punycode) labels that are not valid IDNA parse successfully; the URL +// Standard's host parser requires them to fail. Re-check them here. +static bool isValidCompleteURL(const URL& url) +{ + return url.isValid() && Bun::urlHostIsValidIDNA(url); +} + inline DOMURL::DOMURL(URL&& completeURL) : m_url(WTF::move(completeURL)) , m_initialURLCostForGC(static_cast(std::min(m_url.string().impl()->costDuringGC(), std::numeric_limits::max()))) @@ -65,16 +74,17 @@ inline DOMURL::DOMURL(URL&& completeURL) ExceptionOr> DOMURL::create(const String& url) { URL completeURL { url }; - if (!completeURL.isValid()) + if (!isValidCompleteURL(completeURL)) return Exception { InvalidURLError, makeString(redact(url), " cannot be parsed as a URL."_s) }; return adoptRef(*new DOMURL(WTF::move(completeURL))); } ExceptionOr> DOMURL::create(const String& url, const URL& base) { - ASSERT(base.isValid() || base.isNull()); + // Private overload: create(url, String base), the only caller, already rejected an invalid base. + ASSERT(base.isNull() || isValidCompleteURL(base)); URL completeURL { base, url }; - if (!completeURL.isValid()) + if (!isValidCompleteURL(completeURL)) return Exception { InvalidURLError, makeString(redact(url), " cannot be parsed as a URL."_s) }; return adoptRef(*new DOMURL(WTF::move(completeURL))); } @@ -82,7 +92,7 @@ ExceptionOr> DOMURL::create(const String& url, const URL& base) ExceptionOr> DOMURL::create(const String& url, const String& base) { URL baseURL { base }; - if (!base.isNull() && !baseURL.isValid()) + if (!base.isNull() && !isValidCompleteURL(baseURL)) return Exception { InvalidURLError, makeString(redact(url), " cannot be parsed as a URL against "_s, redact(base)) }; return create(url, baseURL); } @@ -92,9 +102,12 @@ DOMURL::~DOMURL() = default; static URL parseInternal(const String& url, const String& base) { URL baseURL { base }; - if (!base.isNull() && !baseURL.isValid()) + if (!base.isNull() && !isValidCompleteURL(baseURL)) + return {}; + URL completeURL { baseURL, url }; + if (!isValidCompleteURL(completeURL)) return {}; - return { baseURL, url }; + return completeURL; } RefPtr DOMURL::parse(const String& url, const String& base) @@ -113,10 +126,8 @@ bool DOMURL::canParse(const String& url, const String& base) ExceptionOr DOMURL::setHref(const String& url) { URL completeURL { URL {}, url }; - if (!completeURL.isValid()) { - + if (!isValidCompleteURL(completeURL)) return Exception { InvalidURLError, makeString(redact(url), " cannot be parsed as a URL."_s) }; - } m_url = WTF::move(completeURL); if (m_searchParams) m_searchParams->updateFromAssociatedURL(); diff --git a/src/jsc/bindings/NodeURL.cpp b/src/jsc/bindings/NodeURL.cpp index 09af0674a4e9..1a967ffea8d2 100644 --- a/src/jsc/bindings/NodeURL.cpp +++ b/src/jsc/bindings/NodeURL.cpp @@ -1,4 +1,5 @@ #include "NodeURL.h" +#include "BunIDNA.h" #include "wtf/URLParser.h" #include @@ -50,25 +51,15 @@ JSC_DEFINE_HOST_FUNCTION(jsDomainToASCII, (JSC::JSGlobalObject * globalObject, J ) return JSC::JSValue::encode(jsEmptyString(vm)); - if (domain.containsOnlyASCII()) + // An "xn--" (ACE) label must decode to a valid IDNA label, so it cannot + // take the ASCII fast path: https://url.spec.whatwg.org/#concept-domain-to-ascii + if (domain.containsOnlyASCII() && !domainHasACELabel(domain)) return JSC::JSValue::encode(arg0); - if (domain.is8Bit()) - domain.convertTo16Bit(); - constexpr static int allowedNameToASCIIErrors = UIDNA_ERROR_EMPTY_LABEL | UIDNA_ERROR_LABEL_TOO_LONG | UIDNA_ERROR_DOMAIN_NAME_TOO_LONG | UIDNA_ERROR_LEADING_HYPHEN | UIDNA_ERROR_TRAILING_HYPHEN | UIDNA_ERROR_HYPHEN_3_4; - constexpr static size_t hostnameBufferLength = 2048; - - auto encoder = &WTF::URLParser::internationalDomainNameTranscoder(); - char16_t hostnameBuffer[hostnameBufferLength]; - UErrorCode error = U_ZERO_ERROR; - UIDNAInfo processingDetails = UIDNA_INFO_INITIALIZER; - const auto span = domain.span16(); - int32_t numCharactersConverted = uidna_nameToASCII(encoder, span.data(), span.size(), hostnameBuffer, hostnameBufferLength, &processingDetails, &error); - - if (U_SUCCESS(error) && !(processingDetails.errors & ~allowedNameToASCIIErrors) && numCharactersConverted) { - return JSC::JSValue::encode(JSC::jsString(vm, WTF::String(std::span { hostnameBuffer, static_cast(numCharactersConverted) }))); - } - return JSC::JSValue::encode(jsEmptyString(vm)); + auto ascii = domainToASCII(domain); + if (ascii.isNull()) + return JSC::JSValue::encode(jsEmptyString(vm)); + return JSC::JSValue::encode(JSC::jsString(vm, ascii)); } JSC_DEFINE_HOST_FUNCTION(jsDomainToUnicode, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) diff --git a/src/jsc/bindings/URLDecomposition.cpp b/src/jsc/bindings/URLDecomposition.cpp index 200530bb9eee..4f543221b4a3 100644 --- a/src/jsc/bindings/URLDecomposition.cpp +++ b/src/jsc/bindings/URLDecomposition.cpp @@ -25,6 +25,7 @@ #include "URLDecomposition.h" +#include "BunIDNA.h" #include namespace WebCore { @@ -38,7 +39,9 @@ String URLDecomposition::origin() const if (fullURL.protocolIsBlob()) { const String& path = fullURL.path().toString(); const URL subUrl { URL {}, path }; - if (subUrl.isValid()) { + // An inner host with an invalid Punycode label makes this parse fail + // per the spec, which yields an opaque ("null") origin. + if (subUrl.isValid() && Bun::urlHostIsValidIDNA(subUrl)) { if (subUrl.protocolIsInHTTPFamily() or subUrl.protocolIsInFTPFamily() or subUrl.protocolIs("ws"_s) or subUrl.protocolIs("wss"_s) or subUrl.protocolIsFile()) return subUrl.protocolHostAndPort(); } 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 d46b30f8c2e0..bad9de40b8a3 100644 --- a/test/js/node/url/url-domain-ascii-unicode.test.js +++ b/test/js/node/url/url-domain-ascii-unicode.test.js @@ -99,3 +99,27 @@ describe("url.domainToUnicode", () => { }); } }); + +// https://url.spec.whatwg.org/#concept-domain-to-ascii +// An "xn--" (ACE) label must decode to a valid IDNA label; "xn--a-ecp" +// decodes to U+0061 U+2488 (DIGIT ONE FULL STOP), which is disallowed. +describe("url.domainToASCII and invalid Punycode (xn--) labels", () => { + const invalidPunycode = ["xn--a-ecp.example", "sub.xn--a-ecp.example", "XN--A-ECP.example", "xn--pokxncvks", "xn--"]; + for (const domain of invalidPunycode) { + test(`-> '${domain}' is ''`, () => { + expect(url.domainToASCII(domain)).toBe(""); + }); + } + test("a valid ACE label is preserved and lowercased", () => { + expect(url.domainToASCII("XN--BCHER-KVA.DE")).toBe("xn--bcher-kva.de"); + expect(url.domainToASCII("xn--fiqs8s")).toBe("xn--fiqs8s"); + }); + test("a label that merely contains 'xn--' is not an ACE label", () => { + expect(url.domainToASCII("axn--a-ecp.example")).toBe("axn--a-ecp.example"); + }); + test("a domain with an ACE label longer than ICU's stack buffer still round-trips", () => { + // 3017 code units forces the U_BUFFER_OVERFLOW_ERROR retry in Bun::domainToASCII. + const domain = "xn--bcher-kva." + Buffer.alloc(3000, "a").toString() + ".de"; + expect(url.domainToASCII(domain)).toBe(domain); + }); +}); diff --git a/test/js/web/url/url.test.ts b/test/js/web/url/url.test.ts index 818b1152c45a..7d0f70a9253f 100755 --- a/test/js/web/url/url.test.ts +++ b/test/js/web/url/url.test.ts @@ -256,4 +256,96 @@ describe("url", () => { expect(params.get("second")).toBe("replaced"); expect(params.get("third")).toBeNull(); }); + + // https://url.spec.whatwg.org/#concept-domain-to-ascii + // "xn--a-ecp" decodes to U+0061 U+2488 (DIGIT ONE FULL STOP), which is not a + // valid IDNA label, so "domain to ASCII" (and therefore the host parser) must + // fail on it. Node and browsers reject it. + describe("invalid Punycode (xn--) labels in special-scheme hosts", () => { + const invalidHosts = [ + "xn--a-ecp.example", + "sub.xn--a-ecp.example", + "XN--A-ECP.example", + "xn--a-ecp.xn--fiqs8s", + // not decodable as Punycode at all + "xn--pokxncvks", + // empty ACE label + "xn--", + // percent-encoded "x": takes URLParser's percent-decoding slow path + "%78n--a-ecp.example", + ] as const; + + for (const scheme of ["http", "https", "ws", "wss", "ftp", "file"] as const) { + it(`${scheme}: the constructor rejects an invalid xn-- label`, () => { + expect(() => new URL(`${scheme}://xn--a-ecp.example/`)).toThrow("cannot be parsed as a URL"); + expect(() => new URL(`${scheme}://xn--a-ecp.example/`)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_URL" }), + ); + }); + } + + for (const host of invalidHosts) { + const input = `http://${host}/`; + it(`${JSON.stringify(input)} is rejected`, () => { + expect(() => new URL(input)).toThrow("cannot be parsed as a URL"); + expect(URL.canParse(input)).toBe(false); + expect(URL.parse(input)).toBeNull(); + }); + } + + it("the href setter throws and the host/hostname setters are a no-op", () => { + const url = new URL("http://ok.example/p?q#f"); + expect(() => { + url.href = "http://xn--a-ecp.example/"; + }).toThrow("cannot be parsed as a URL"); + url.host = "xn--a-ecp.example"; + url.hostname = "xn--a-ecp.example"; + expect(url.href).toBe("http://ok.example/p?q#f"); + }); + + it("a base URL with an invalid xn-- label is rejected", () => { + expect(() => new URL("//xn--a-ecp.example/x", "http://ok.example/")).toThrow("cannot be parsed as a URL"); + expect(() => new URL("http://ok.example/", "http://xn--a-ecp.example/")).toThrow("cannot be parsed as a URL"); + expect(URL.canParse("/x", "http://xn--a-ecp.example/")).toBe(false); + }); + + it("valid ACE labels and non-ACE ASCII hosts still parse", () => { + 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--nxasmm1c/": "xn--nxasmm1c", + "http://xn--e1afmkfd.xn--p1ai/": "xn--e1afmkfd.xn--p1ai", + // "axn--a-ecp" merely contains "xn--"; it is not an ACE label + "http://axn--a-ecp.example/": "axn--a-ecp.example", + "http://ab--cd.example/": "ab--cd.example", + "http://a_b.example/": "a_b.example", + "http://r4---sn-a5mlrn7s.gevideo.com/": "r4---sn-a5mlrn7s.gevideo.com", + "http://-sn--a5mlrn7s-.gevideo.com/": "-sn--a5mlrn7s-.gevideo.com", + }; + expect(Object.fromEntries(Object.keys(accepted).map(input => [input, new URL(input).hostname]))).toEqual( + accepted, + ); + }); + + it("opaque hosts of non-special schemes are not IDNA-validated", () => { + expect(new URL("foo://xn--a-ecp.example/").hostname).toBe("xn--a-ecp.example"); + expect(new URL("foo://XN--A-ECP.example/").hostname).toBe("XN--A-ECP.example"); + }); + + it("blob: origin re-parses the inner URL with the same host validation", () => { + // The origin of a blob: URL is the origin of its parsed path; an invalid + // inner host makes that parse fail, which yields an opaque ("null") origin. + expect(new URL("blob:http://xn--a-ecp.example/foo").origin).toBe("null"); + expect(new URL("blob:http://xn--bcher-kva.de/foo").origin).toBe("http://xn--bcher-kva.de"); + expect(new URL("blob:http://ok.example/foo").origin).toBe("http://ok.example"); + }); + + it("a host with an ACE label longer than ICU's stack buffer still parses", () => { + // 3017 code units forces the U_BUFFER_OVERFLOW_ERROR retry in Bun::domainToASCII. + const host = "xn--bcher-kva." + Buffer.alloc(3000, "a").toString() + ".de"; + expect(new URL(`http://${host}/`).hostname).toBe(host); + expect(URL.canParse(`http://${host}/`)).toBe(true); + }); + }); });