From c04c400f4184e4c1cadb9b7ccbeb497539bc75ac Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:39:52 +0000 Subject: [PATCH 1/2] url: reject invalid Punycode (xn--) labels in special-scheme hosts WTF::URLParser takes an all-ASCII fast path for special-scheme hosts that never runs UTS-46 "domain to ASCII", so an "xn--" label that does not decode to a valid IDNA label (for example "xn--a-ecp") parses successfully. Node and the URL Standard's host parser reject it. Add Bun::urlHostIsValidIDNA, which re-runs ICU's uidna_nameToASCII on a parsed special-scheme host, but only when one of its labels starts with "xn--"; for every other all-ASCII host the fast path is already equivalent to the spec. Wire it into DOMURL's create, parse, canParse, and setHref, which also covers the href, host, and hostname setters. url.domainToASCII had the same all-ASCII early return; gate it on the same predicate and route ACE labels through the shared helper. --- src/jsc/bindings/BunIDNA.cpp | 50 ++++++++++++ src/jsc/bindings/BunIDNA.h | 24 ++++++ src/jsc/bindings/DOMURL.cpp | 29 ++++--- src/jsc/bindings/NodeURL.cpp | 25 ++---- .../node/url/url-domain-ascii-unicode.test.js | 19 +++++ test/js/web/url/url.test.ts | 77 +++++++++++++++++++ 6 files changed, 198 insertions(+), 26 deletions(-) create mode 100644 src/jsc/bindings/BunIDNA.cpp create mode 100644 src/jsc/bindings/BunIDNA.h diff --git a/src/jsc/bindings/BunIDNA.cpp b/src/jsc/bindings/BunIDNA.cpp new file mode 100644 index 000000000000..94aacbd42016 --- /dev/null +++ b/src/jsc/bindings/BunIDNA.cpp @@ -0,0 +1,50 @@ +#include "root.h" +#include "BunIDNA.h" + +#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 buffer; + UErrorCode error = U_ZERO_ERROR; + UIDNAInfo processingDetails = UIDNA_INFO_INITIALIZER; + int32_t length = uidna_nameToASCII(&WTF::URLParser::internationalDomainNameTranscoder(), domain.upconvertedCharacters(), domain.length(), buffer.data(), WTF::URLParser::hostnameBufferLength, &processingDetails, &error); + if (U_SUCCESS(error) && !(processingDetails.errors & ~WTF::URLParser::allowedNameToASCIIErrors) && length > 0) + return WTF::String { std::span { buffer }.first(static_cast(length)) }; + return {}; +} + +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/test/js/node/url/url-domain-ascii-unicode.test.js b/test/js/node/url/url-domain-ascii-unicode.test.js index d46b30f8c2e0..431bb331e864 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,22 @@ 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"); + }); +}); diff --git a/test/js/web/url/url.test.ts b/test/js/web/url/url.test.ts index 818b1152c45a..43834a9ea3a2 100755 --- a/test/js/web/url/url.test.ts +++ b/test/js/web/url/url.test.ts @@ -256,4 +256,81 @@ 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"); + }); + }); }); From 696eb976f63be3ad88fcdb3c77c81e6cab0b1c6a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:26:53 +0000 Subject: [PATCH 2/2] url: handle ICU buffer overflow and validate the blob: origin's inner host Bun::domainToASCII used a fixed 2048-code-unit buffer and treated U_BUFFER_OVERFLOW_ERROR as "invalid IDNA", so an all-ASCII host longer than that which happens to contain an "xn--" label was newly rejected. Retry once with a heap buffer of the length ICU reports, following ICU's preflight convention. The URL Standard puts no length limit on a domain, and Node accepts these. URLDecomposition::origin() re-parses a blob: URL's path with a raw WTF::URL and only checked isValid(), so an invalid "xn--" label in the inner host still produced a tuple origin. Apply the same host check so the inner parse fails and the origin serializes to "null", as Node does. --- src/jsc/bindings/BunIDNA.cpp | 25 +++++++++++++------ src/jsc/bindings/URLDecomposition.cpp | 5 +++- .../node/url/url-domain-ascii-unicode.test.js | 5 ++++ test/js/web/url/url.test.ts | 15 +++++++++++ 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/jsc/bindings/BunIDNA.cpp b/src/jsc/bindings/BunIDNA.cpp index 94aacbd42016..c98870ebd07c 100644 --- a/src/jsc/bindings/BunIDNA.cpp +++ b/src/jsc/bindings/BunIDNA.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -24,13 +25,23 @@ bool domainHasACELabel(WTF::StringView domain) WTF::String domainToASCII(WTF::StringView domain) { - std::array buffer; - UErrorCode error = U_ZERO_ERROR; - UIDNAInfo processingDetails = UIDNA_INFO_INITIALIZER; - int32_t length = uidna_nameToASCII(&WTF::URLParser::internationalDomainNameTranscoder(), domain.upconvertedCharacters(), domain.length(), buffer.data(), WTF::URLParser::hostnameBufferLength, &processingDetails, &error); - if (U_SUCCESS(error) && !(processingDetails.errors & ~WTF::URLParser::allowedNameToASCIIErrors) && length > 0) - return WTF::String { std::span { buffer }.first(static_cast(length)) }; - return {}; + 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) 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 431bb331e864..bad9de40b8a3 100644 --- a/test/js/node/url/url-domain-ascii-unicode.test.js +++ b/test/js/node/url/url-domain-ascii-unicode.test.js @@ -117,4 +117,9 @@ describe("url.domainToASCII and invalid Punycode (xn--) labels", () => { 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 43834a9ea3a2..7d0f70a9253f 100755 --- a/test/js/web/url/url.test.ts +++ b/test/js/web/url/url.test.ts @@ -332,5 +332,20 @@ describe("url", () => { 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); + }); }); });