From 680953d2dbcf159ac9c593eeaa458569eaf99f28 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:02:39 +0000 Subject: [PATCH 1/9] url: accept IDNA hostnames reclassified from disallowed to mapped in Unicode 16.0 The bundled ICU prebuilts (75.1 on Linux/musl, 73.2 on Windows) derive their uts46.nrm from Unicode 15.x's IdnaMappingTable.txt, which marks a family of late-casefolded capitals as 'disallowed': U+04C0 CYRILLIC LETTER PALOCHKA, U+10A0..10C5 Georgian capitals, U+2132 TURNED CAPITAL F, U+2183 ROMAN NUMERAL REVERSED ONE HUNDRED, plus several format controls. Unicode 16.0's IdnaMappingTable.txt changed these to 'mapped'/'ignored', so WHATWG URL now expects them to parse. Ship a uts46.nrm regenerated from Unicode 16.0's data in Nrm2 format version 4 (readable by ICU 73/75) and swap it in via the existing ICU udata hook (bun_icu_maybe_decompress), matched by its exact 48-byte header prefix. The override is a no-op on macOS (system ICU) and self-disables once the prebuilts bundle ICU 76 or later. --- scripts/regenerate-uts46-override.sh | 46 ++++++++++++++++++++++ src/jsc/bindings/bun_icu_decompress.cpp | 38 +++++++++++++++++- src/jsc/bindings/icu_uts46_override.nrm | Bin 0 -> 60252 bytes test/js/web/url/url.test.ts | 50 +++++++++++++++++++++++- 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100755 scripts/regenerate-uts46-override.sh create mode 100644 src/jsc/bindings/icu_uts46_override.nrm diff --git a/scripts/regenerate-uts46-override.sh b/scripts/regenerate-uts46-override.sh new file mode 100755 index 000000000000..abcc0e442ca4 --- /dev/null +++ b/scripts/regenerate-uts46-override.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Regenerates src/jsc/bindings/icu_uts46_override.nrm. +# +# The bundled ICU prebuilts (ICU 75.1 on Linux/musl, ICU 73.2 on Windows) ship +# uts46.nrm data derived from Unicode 15.x's IdnaMappingTable.txt, which marks +# several late-casefolded capitals as "disallowed" (U+04C0, U+10A0..10C5, +# U+2132, U+2183, ...). Unicode 16.0's IdnaMappingTable.txt changed these to +# "mapped". This script produces a uts46.nrm carrying the Unicode 16.0 mappings +# in Nrm2 format version 4 (the format ICU 73/75's Normalizer2 reads), which +# bun_icu_decompress.cpp swaps in at runtime via the udata hook. +# +# Delete the override once the oven-sh/WebKit prebuilt bundles ICU 76 or later. +set -euo pipefail + +ICU_TOOLCHAIN_TAG=release-75-1 # whose gennorm2 to use (emits Nrm2 format v4) +ICU_TOOLCHAIN_SRC=icu4c-75_1-src.tgz +ICU_DATA_TAG=release-76-1 # whose norm2/uts46.txt to compile + +cd "$(dirname "$0")/.." +OUT="src/jsc/bindings/icu_uts46_override.nrm" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +curl -fL "https://github.com/unicode-org/icu/releases/download/${ICU_TOOLCHAIN_TAG}/${ICU_TOOLCHAIN_SRC}" \ + | tar -xz -C "$WORK" +pushd "$WORK/icu/source" >/dev/null +./configure --enable-static --disable-shared --with-data-packaging=archive \ + --disable-samples --disable-tests --disable-extras --disable-icuio >/dev/null +make -j"$(nproc)" >/dev/null +popd >/dev/null + +curl -fL "https://raw.githubusercontent.com/unicode-org/icu/${ICU_DATA_TAG}/icu4c/source/data/unidata/norm2/uts46.txt" \ + -o "$WORK/icu/source/data/unidata/norm2/uts46.txt" + +LD_LIBRARY_PATH="$WORK/icu/source/lib:$WORK/icu/source/stubdata" \ + "$WORK/icu/source/bin/gennorm2" -o "$OUT" \ + -s "$WORK/icu/source/data/unidata/norm2" nfc.txt uts46.txt + +# Format-version sanity check: byte 16 must be 4. +fmt=$(od -An -t u1 -j 16 -N 1 "$OUT" | tr -d ' ') +if [ "$fmt" != "4" ]; then + echo "error: generated $OUT has Nrm2 format version $fmt, expected 4" >&2 + exit 1 +fi + +echo "wrote $OUT ($(wc -c <"$OUT") bytes)" diff --git a/src/jsc/bindings/bun_icu_decompress.cpp b/src/jsc/bindings/bun_icu_decompress.cpp index d17798d3023f..dd0cf9195774 100644 --- a/src/jsc/bindings/bun_icu_decompress.cpp +++ b/src/jsc/bindings/bun_icu_decompress.cpp @@ -46,6 +46,42 @@ extern "C" __attribute__((weak)) const unsigned int bun_icu_zstd_dict_size; namespace Bun { +// Replacement uts46.nrm carrying the Unicode 16.0 IdnaMappingTable (UTS #46 +// rev. 33) in Nrm2 format version 4, readable by the ICU 73/75 the prebuilts +// bundle. Regenerate via scripts/regenerate-uts46-override.sh. +alignas(16) static constexpr uint8_t s_uts46Override[] = { +#embed "icu_uts46_override.nrm" +}; + +// The bundled prebuilts' uts46.nrm predates Unicode 16.0, which reclassified +// U+04C0, U+10A0..10C5, U+2132, U+2183 et al. from "disallowed" to "mapped". +// Match by 48-byte prefix (DataHeader + first four Nrm2 indexes, unique per *.nrm). +static const void* maybeOverrideUTS46(const void* p, int32_t* length) +{ + // clang-format off + static constexpr uint8_t kUTS46Prefix75[48] = { + 0x20, 0x00, 0xda, 0x27, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x4e, 0x72, 0x6d, 0x32, + 0x04, 0x00, 0x00, 0x00, 0x0f, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x50, 0x00, 0x00, 0x00, 0xc0, 0x93, 0x00, 0x00, 0x8c, 0xe8, 0x00, 0x00, 0x8c, 0xe9, 0x00, 0x00, + }; + static constexpr uint8_t kUTS46Prefix73[48] = { + 0x20, 0x00, 0xda, 0x27, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x4e, 0x72, 0x6d, 0x32, + 0x04, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x50, 0x00, 0x00, 0x00, 0x84, 0x93, 0x00, 0x00, 0x4c, 0xe8, 0x00, 0x00, 0x4c, 0xe9, 0x00, 0x00, + }; + // clang-format on + static_assert(s_uts46Override[12] == 'N' && s_uts46Override[13] == 'r' && s_uts46Override[16] == 4, + "icu_uts46_override.nrm must be Nrm2 format version 4"); + + if (*length >= static_cast(sizeof(kUTS46Prefix75)) + && (std::memcmp(p, kUTS46Prefix75, sizeof(kUTS46Prefix75)) == 0 + || std::memcmp(p, kUTS46Prefix73, sizeof(kUTS46Prefix73)) == 0)) { + *length = static_cast(sizeof(s_uts46Override)); + return s_uts46Override; + } + return p; +} + class ICUDecompressor { public: static ICUDecompressor& get() @@ -119,7 +155,7 @@ extern "C" const void* bun_icu_maybe_decompress(const void* p, int32_t* length) uint32_t magic; std::memcpy(&magic, p, sizeof(magic)); if (magic != ZSTD_MAGICNUMBER) [[likely]] - return p; + return Bun::maybeOverrideUTS46(p, length); return Bun::ICUDecompressor::get().decompress(p, length); } diff --git a/src/jsc/bindings/icu_uts46_override.nrm b/src/jsc/bindings/icu_uts46_override.nrm new file mode 100644 index 0000000000000000000000000000000000000000..5fd716d8577dbb40bb9dbf6c770044a5126221d2 GIT binary patch literal 60252 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 { it("URL throws", () => { @@ -332,3 +333,50 @@ describe("object URL prefix check", () => { }); }, 60_000); }); + +// Unicode 16.0 UTS #46 reclassified several codepoints from "disallowed" to +// "mapped"/"ignored"; the bundled ICU predates that. The uts46.nrm override in +// bun_icu_decompress.cpp only applies on Linux/Windows (udata hook platforms). +describe.skipIf(!(isLinux || isWindows))("IDNA UTS #46 Unicode 16.0 mappings", () => { + it("maps late-casefolded capitals instead of rejecting them", () => { + expect( + [ + "https://\u04C0/", // CYRILLIC LETTER PALOCHKA + "https://\u10AC/", // GEORGIAN CAPITAL LETTER NAR + "https://a\u10B5/", // a + GEORGIAN CAPITAL LETTER KHAR + "https://\u2132/", // TURNED CAPITAL F + "https://\u2183/", // ROMAN NUMERAL REVERSED ONE HUNDRED + "https://\u1874\u10A0/", // MONGOLIAN LETTER ... + GEORGIAN CAPITAL AN + "https://\uA846\u3002\u2183\u0FB5\uB1AE-/", // WPT IdnaTestV2 "V3 (ignored)" case + ].map(href => new URL(href).hostname), + ).toEqual([ + "xn--s5a", + "xn--3kj", + "xn--a-hws", + "xn--73g", + "xn--r5g", + "xn--h9e436h", + "xn--fc9a.xn----qmg097k469k", + ]); + }); + + it("ignores format controls reclassified in Unicode 16.0", () => { + expect( + [ + "https://a\u180Eb/", // MONGOLIAN VOWEL SEPARATOR + "https://a\u2061b/", // FUNCTION APPLICATION + "https://a\u3164b/", // HANGUL FILLER + ].map(href => new URL(href).hostname), + ).toEqual(["ab", "ab", "ab"]); + }); + + it("node:url domainToASCII applies the same mapping", () => { + expect(domainToASCII("\u10AC")).toBe("xn--3kj"); + expect(domainToASCII("\u2183")).toBe("xn--r5g"); + }); + + it("still maps pre-existing UTS #46 cases", () => { + expect(new URL("https://M\u00FCnchen.de/").hostname).toBe("xn--mnchen-3ya.de"); + expect(new URL("https://\u0391/").hostname).toBe("xn--mxa"); + }); +}); From d32f0267a910550608f96115622858206285ffdb Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:04:28 +0000 Subject: [PATCH 2/9] [autofix.ci] apply automated fixes --- test/js/web/url/url.test.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/test/js/web/url/url.test.ts b/test/js/web/url/url.test.ts index a1f8f92b42af..e0293a433bea 100755 --- a/test/js/web/url/url.test.ts +++ b/test/js/web/url/url.test.ts @@ -349,15 +349,7 @@ describe.skipIf(!(isLinux || isWindows))("IDNA UTS #46 Unicode 16.0 mappings", ( "https://\u1874\u10A0/", // MONGOLIAN LETTER ... + GEORGIAN CAPITAL AN "https://\uA846\u3002\u2183\u0FB5\uB1AE-/", // WPT IdnaTestV2 "V3 (ignored)" case ].map(href => new URL(href).hostname), - ).toEqual([ - "xn--s5a", - "xn--3kj", - "xn--a-hws", - "xn--73g", - "xn--r5g", - "xn--h9e436h", - "xn--fc9a.xn----qmg097k469k", - ]); + ).toEqual(["xn--s5a", "xn--3kj", "xn--a-hws", "xn--73g", "xn--r5g", "xn--h9e436h", "xn--fc9a.xn----qmg097k469k"]); }); it("ignores format controls reclassified in Unicode 16.0", () => { From 45af3b8f86450a462524201dc34eb010bbaf5420 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:51:48 +0000 Subject: [PATCH 3/9] ci: retrigger From 7499cbca18069afbd28c3da423a2a0ab90c483f8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:03:37 +0000 Subject: [PATCH 4/9] scripts: pin SHA-256 for ICU inputs, use absolute output path, trim header comment --- scripts/regenerate-uts46-override.sh | 42 +++++++++++++--------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/scripts/regenerate-uts46-override.sh b/scripts/regenerate-uts46-override.sh index abcc0e442ca4..7f3b29b4c360 100755 --- a/scripts/regenerate-uts46-override.sh +++ b/scripts/regenerate-uts46-override.sh @@ -1,46 +1,42 @@ #!/usr/bin/env bash -# Regenerates src/jsc/bindings/icu_uts46_override.nrm. -# -# The bundled ICU prebuilts (ICU 75.1 on Linux/musl, ICU 73.2 on Windows) ship -# uts46.nrm data derived from Unicode 15.x's IdnaMappingTable.txt, which marks -# several late-casefolded capitals as "disallowed" (U+04C0, U+10A0..10C5, -# U+2132, U+2183, ...). Unicode 16.0's IdnaMappingTable.txt changed these to -# "mapped". This script produces a uts46.nrm carrying the Unicode 16.0 mappings -# in Nrm2 format version 4 (the format ICU 73/75's Normalizer2 reads), which -# bun_icu_decompress.cpp swaps in at runtime via the udata hook. -# -# Delete the override once the oven-sh/WebKit prebuilt bundles ICU 76 or later. +# Regenerates src/jsc/bindings/icu_uts46_override.nrm: a Unicode 16.0 UTS #46 +# uts46.nrm in Nrm2 format v4 (readable by the bundled ICU 73/75), swapped in at +# runtime by bun_icu_decompress.cpp. Drop once the WebKit prebuilt ships ICU 76+. set -euo pipefail ICU_TOOLCHAIN_TAG=release-75-1 # whose gennorm2 to use (emits Nrm2 format v4) ICU_TOOLCHAIN_SRC=icu4c-75_1-src.tgz +ICU_TOOLCHAIN_SHA256=cb968df3e4d2e87e8b11c49a5d01c787bd13b9545280fc6642f826527618caef ICU_DATA_TAG=release-76-1 # whose norm2/uts46.txt to compile +ICU_DATA_SHA256=fda2c1c636d71db2cc685ca7671aff8efa27e83d84ee322cc3ce1375e53300d8 -cd "$(dirname "$0")/.." -OUT="src/jsc/bindings/icu_uts46_override.nrm" +REPO_ROOT="$(cd -- "$(dirname -- "$0")/.." && pwd)" +OUT="$REPO_ROOT/src/jsc/bindings/icu_uts46_override.nrm" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT -curl -fL "https://github.com/unicode-org/icu/releases/download/${ICU_TOOLCHAIN_TAG}/${ICU_TOOLCHAIN_SRC}" \ - | tar -xz -C "$WORK" +fetch() { + curl -fL --proto '=https' --tlsv1.2 "$1" -o "$2" + echo "$3 $2" | sha256sum -c - +} + +fetch "https://github.com/unicode-org/icu/releases/download/${ICU_TOOLCHAIN_TAG}/${ICU_TOOLCHAIN_SRC}" \ + "$WORK/icu.tgz" "$ICU_TOOLCHAIN_SHA256" +tar -xzf "$WORK/icu.tgz" -C "$WORK" pushd "$WORK/icu/source" >/dev/null ./configure --enable-static --disable-shared --with-data-packaging=archive \ --disable-samples --disable-tests --disable-extras --disable-icuio >/dev/null make -j"$(nproc)" >/dev/null popd >/dev/null -curl -fL "https://raw.githubusercontent.com/unicode-org/icu/${ICU_DATA_TAG}/icu4c/source/data/unidata/norm2/uts46.txt" \ - -o "$WORK/icu/source/data/unidata/norm2/uts46.txt" +fetch "https://raw.githubusercontent.com/unicode-org/icu/${ICU_DATA_TAG}/icu4c/source/data/unidata/norm2/uts46.txt" \ + "$WORK/icu/source/data/unidata/norm2/uts46.txt" "$ICU_DATA_SHA256" LD_LIBRARY_PATH="$WORK/icu/source/lib:$WORK/icu/source/stubdata" \ "$WORK/icu/source/bin/gennorm2" -o "$OUT" \ -s "$WORK/icu/source/data/unidata/norm2" nfc.txt uts46.txt -# Format-version sanity check: byte 16 must be 4. fmt=$(od -An -t u1 -j 16 -N 1 "$OUT" | tr -d ' ') -if [ "$fmt" != "4" ]; then - echo "error: generated $OUT has Nrm2 format version $fmt, expected 4" >&2 - exit 1 -fi +[ "$fmt" = "4" ] || { echo "error: $OUT has Nrm2 format version $fmt, expected 4" >&2; exit 1; } -echo "wrote $OUT ($(wc -c <"$OUT") bytes)" +echo "wrote $OUT ($(wc -c <"$OUT") bytes, md5 $(md5sum "$OUT" | cut -d' ' -f1))" From f8feb903ec8209cbc6393279b120d1bd03c01c01 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 5/9] 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 e0293a433bea..53c241ae1817 100755 --- a/test/js/web/url/url.test.ts +++ b/test/js/web/url/url.test.ts @@ -259,6 +259,83 @@ 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"); + }); + }); }); describe("object URL prefix check", () => { From 8fa292a6940cab3769d0242349bd198240bdaba9 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 6/9] 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 53c241ae1817..e452b9a4d88a 100755 --- a/test/js/web/url/url.test.ts +++ b/test/js/web/url/url.test.ts @@ -335,6 +335,21 @@ 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); + }); }); }); From 4ae7db66b33722564e0a3a2584527ca6410e5554 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:21:54 +0000 Subject: [PATCH 7/9] test: add ContextJ (xn--0ug) cases to the Punycode harness --- test/js/web/url/url.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/js/web/url/url.test.ts b/test/js/web/url/url.test.ts index e452b9a4d88a..2cca3bb61232 100755 --- a/test/js/web/url/url.test.ts +++ b/test/js/web/url/url.test.ts @@ -270,12 +270,15 @@ describe("url", () => { "sub.xn--a-ecp.example", "XN--A-ECP.example", "xn--a-ecp.xn--fiqs8s", + // decodes to U+200D ZWJ: ICU UIDNA_ERROR_CONTEXTJ + "xn--0ug", // 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", + "%78n--0ug", ] as const; for (const scheme of ["http", "https", "ws", "wss", "ftp", "file"] as const) { From f2101099e264a6625f034c83ad7d59a9d67259dc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:24:50 +0000 Subject: [PATCH 8/9] trim multi-line comments to single lines --- src/jsc/bindings/BunIDNA.cpp | 8 +------- src/jsc/bindings/BunIDNA.h | 12 +++--------- src/jsc/bindings/DOMURL.cpp | 3 --- src/jsc/bindings/NodeURL.cpp | 2 -- src/jsc/bindings/URLDecomposition.cpp | 2 -- src/jsc/bindings/bun_icu_decompress.cpp | 8 ++------ 6 files changed, 6 insertions(+), 29 deletions(-) diff --git a/src/jsc/bindings/BunIDNA.cpp b/src/jsc/bindings/BunIDNA.cpp index c98870ebd07c..577f32dac2c9 100644 --- a/src/jsc/bindings/BunIDNA.cpp +++ b/src/jsc/bindings/BunIDNA.cpp @@ -34,9 +34,7 @@ WTF::String domainToASCII(WTF::StringView domain) 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. + // ICU preflight: on overflow `length` is the required size; retry once with a heap buffer. if (error != U_BUFFER_OVERFLOW_ERROR || length <= 0 || !heapBuffer.isEmpty()) return {}; heapBuffer.grow(static_cast(length)); @@ -46,12 +44,8 @@ WTF::String domainToASCII(WTF::StringView domain) 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; diff --git a/src/jsc/bindings/BunIDNA.h b/src/jsc/bindings/BunIDNA.h index 982e1b3f68b3..e6154964cfcd 100644 --- a/src/jsc/bindings/BunIDNA.h +++ b/src/jsc/bindings/BunIDNA.h @@ -6,19 +6,13 @@ 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. +// Does any dot-separated label start with "xn--" (ASCII case-insensitive)? 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. +// https://url.spec.whatwg.org/#concept-domain-to-ascii (beStrict = false); null on failure. 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. +// WTF::URLParser skips UTS-46 for all-ASCII hosts, so an invalid "xn--" label parses; re-check it here. bool urlHostIsValidIDNA(const WTF::URL&); } // namespace Bun diff --git a/src/jsc/bindings/DOMURL.cpp b/src/jsc/bindings/DOMURL.cpp index f775d8277268..e70054fb3004 100644 --- a/src/jsc/bindings/DOMURL.cpp +++ b/src/jsc/bindings/DOMURL.cpp @@ -56,9 +56,6 @@ 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); diff --git a/src/jsc/bindings/NodeURL.cpp b/src/jsc/bindings/NodeURL.cpp index 1a967ffea8d2..5e1bc78e66dd 100644 --- a/src/jsc/bindings/NodeURL.cpp +++ b/src/jsc/bindings/NodeURL.cpp @@ -51,8 +51,6 @@ JSC_DEFINE_HOST_FUNCTION(jsDomainToASCII, (JSC::JSGlobalObject * globalObject, J ) return JSC::JSValue::encode(jsEmptyString(vm)); - // 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); diff --git a/src/jsc/bindings/URLDecomposition.cpp b/src/jsc/bindings/URLDecomposition.cpp index 4f543221b4a3..ac04439c5f88 100644 --- a/src/jsc/bindings/URLDecomposition.cpp +++ b/src/jsc/bindings/URLDecomposition.cpp @@ -39,8 +39,6 @@ String URLDecomposition::origin() const if (fullURL.protocolIsBlob()) { const String& path = fullURL.path().toString(); const URL subUrl { URL {}, path }; - // 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/src/jsc/bindings/bun_icu_decompress.cpp b/src/jsc/bindings/bun_icu_decompress.cpp index dd0cf9195774..a51b7f8e61a0 100644 --- a/src/jsc/bindings/bun_icu_decompress.cpp +++ b/src/jsc/bindings/bun_icu_decompress.cpp @@ -46,16 +46,12 @@ extern "C" __attribute__((weak)) const unsigned int bun_icu_zstd_dict_size; namespace Bun { -// Replacement uts46.nrm carrying the Unicode 16.0 IdnaMappingTable (UTS #46 -// rev. 33) in Nrm2 format version 4, readable by the ICU 73/75 the prebuilts -// bundle. Regenerate via scripts/regenerate-uts46-override.sh. +// Unicode 16.0 uts46.nrm in Nrm2 format v4; regenerate via scripts/regenerate-uts46-override.sh. alignas(16) static constexpr uint8_t s_uts46Override[] = { #embed "icu_uts46_override.nrm" }; -// The bundled prebuilts' uts46.nrm predates Unicode 16.0, which reclassified -// U+04C0, U+10A0..10C5, U+2132, U+2183 et al. from "disallowed" to "mapped". -// Match by 48-byte prefix (DataHeader + first four Nrm2 indexes, unique per *.nrm). +// Swap the bundled pre-Unicode-16.0 uts46.nrm for s_uts46Override, matched by its 48-byte DataHeader+indexes prefix. static const void* maybeOverrideUTS46(const void* p, int32_t* length) { // clang-format off From 1d27a31e2f4643fed77421fb04d90dd495099ce5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:38:16 +0000 Subject: [PATCH 9/9] URLDecomposition: gate setHost/setHostname commit on urlHostIsValidIDNA directly --- src/jsc/bindings/URLDecomposition.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jsc/bindings/URLDecomposition.cpp b/src/jsc/bindings/URLDecomposition.cpp index ac04439c5f88..f85352e328d2 100644 --- a/src/jsc/bindings/URLDecomposition.cpp +++ b/src/jsc/bindings/URLDecomposition.cpp @@ -137,7 +137,7 @@ void URLDecomposition::setHost(StringView value) fullURL.setHostAndPort(value.left(separator + 1 + portLength)); } } - if (fullURL.isValid()) + if (fullURL.isValid() && Bun::urlHostIsValidIDNA(fullURL)) setFullURL(fullURL); } @@ -154,7 +154,7 @@ void URLDecomposition::setHostname(StringView host) if (fullURL.hasOpaquePath()) return; fullURL.setHost(host); - if (fullURL.isValid()) + if (fullURL.isValid() && Bun::urlHostIsValidIDNA(fullURL)) setFullURL(fullURL); }