diff --git a/src/jsc/bindings/NodeURL.cpp b/src/jsc/bindings/NodeURL.cpp index 09af0674a4e..92c733cf3f9 100644 --- a/src/jsc/bindings/NodeURL.cpp +++ b/src/jsc/bindings/NodeURL.cpp @@ -4,6 +4,210 @@ namespace Bun { +// RFC 3492 Punycode encoder without ICU's ENCODE_MAX_CODE_UNITS (=1000) cap. +// Used as a fallback when a single label is too long for u_strToPunycode. +// Appends the encoding of `label` (UTF-16) to `out`, not including the "xn--" prefix. +static bool punycodeEncodeLabel(std::span label, Vector& out) +{ + constexpr char32_t base = 36, tMin = 1, tMax = 26, skew = 38, damp = 700; + constexpr char32_t initialBias = 72, initialN = 0x80; + auto adapt = [](char32_t delta, char32_t numPoints, bool firstTime) { + delta = firstTime ? delta / damp : delta / 2; + delta += delta / numPoints; + char32_t k = 0; + while (delta > ((base - tMin) * tMax) / 2) { + delta /= base - tMin; + k += base; + } + return k + (base - tMin + 1) * delta / (delta + skew); + }; + auto encodeDigit = [](char32_t d) -> char16_t { + return static_cast(d + (d < 26 ? 'a' : ('0' - 26))); + }; + + Vector codePoints; + codePoints.reserveCapacity(label.size()); + for (size_t i = 0; i < label.size(); ++i) { + char16_t c = label[i]; + if (U16_IS_LEAD(c) && i + 1 < label.size() && U16_IS_TRAIL(label[i + 1])) { + codePoints.append(U16_GET_SUPPLEMENTARY(c, label[i + 1])); + ++i; + } else if (U16_IS_SURROGATE(c)) { + return false; + } else + codePoints.append(c); + } + + char32_t n = initialN, bias = initialBias; + size_t handled = 0; + for (auto cp : codePoints) { + if (cp < 0x80) { + out.append(static_cast(cp)); + ++handled; + } + } + size_t basic = handled; + if (basic) + out.append('-'); + + WTF::CheckedUint32 delta = 0; + while (handled < codePoints.size()) { + char32_t m = std::numeric_limits::max(); + for (auto cp : codePoints) { + if (cp >= n && cp < m) + m = cp; + } + delta += WTF::CheckedUint32(m - n) * WTF::CheckedUint32(static_cast(handled + 1)); + if (delta.hasOverflowed()) + return false; + n = m; + for (auto cp : codePoints) { + if (cp < n) { + delta += 1; + if (delta.hasOverflowed()) + return false; + } else if (cp == n) { + char32_t q = delta.value(); + for (char32_t k = base;; k += base) { + char32_t t = k <= bias ? tMin : (k >= bias + tMax ? tMax : k - bias); + if (q < t) + break; + out.append(encodeDigit(t + (q - t) % (base - t))); + q = (q - t) / (base - t); + } + out.append(encodeDigit(q)); + bias = adapt(delta.value(), static_cast(handled + 1), handled == basic); + delta = 0; + ++handled; + } + } + delta += 1; + ++n; + } + return true; +} + +// Fallback for uidna_nameToASCII when a label exceeds ICU's ENCODE_MAX_CODE_UNITS: +// run UTS #46 ToUnicode (which does mapping/NFC/validation but no Punycode encode +// for non-ACE labels) then Punycode-encode each non-ASCII label ourselves. +static bool nameToASCIIFallback(const UIDNA& transcoder, const char16_t* src, int32_t srcLength, Vector& ascii, UIDNAInfo& processingDetails) +{ + Vector unicode; + UIDNAInfo unicodeInfo = UIDNA_INFO_INITIALIZER; + UErrorCode error = U_ZERO_ERROR; + int32_t len = uidna_nameToUnicode(&transcoder, src, srcLength, nullptr, 0, &unicodeInfo, &error); + if (error != U_BUFFER_OVERFLOW_ERROR || len <= 0) + return false; + if (!unicode.tryGrow(static_cast(len))) + return false; + error = U_ZERO_ERROR; + unicodeInfo = UIDNA_INFO_INITIALIZER; + len = uidna_nameToUnicode(&transcoder, src, srcLength, unicode.mutableSpan().data(), static_cast(unicode.size()), &unicodeInfo, &error); + if (U_FAILURE(error)) + return false; + unicode.shrink(static_cast(len)); + processingDetails.errors = unicodeInfo.errors; + if (unicodeInfo.errors & ~WTF::URLParser::allowedNameToASCIIErrors) + return false; + + ascii.reserveCapacity(unicode.size() + 16); + size_t labelStart = 0; + auto emitLabel = [&](size_t start, size_t end) -> bool { + auto label = unicode.subspan(start, end - start); + bool allAscii = true; + for (auto c : label) { + if (c >= 0x80) { + allAscii = false; + break; + } + } + if (allAscii) { + ascii.append(label); + if (label.size() > 63) + processingDetails.errors |= UIDNA_ERROR_LABEL_TOO_LONG; + return true; + } + size_t before = ascii.size(); + ascii.append('x'); + ascii.append('n'); + ascii.append('-'); + ascii.append('-'); + if (!punycodeEncodeLabel(label, ascii)) + return false; + if (ascii.size() - before > 63) + processingDetails.errors |= UIDNA_ERROR_LABEL_TOO_LONG; + return true; + }; + for (size_t i = 0; i < unicode.size(); ++i) { + if (unicode[i] == '.') { + if (!emitLabel(labelStart, i)) + return false; + ascii.append('.'); + labelStart = i + 1; + } + } + if (!emitLabel(labelStart, unicode.size())) + return false; + if (ascii.size() > 253 && (ascii.size() > 254 || ascii.last() != '.')) + processingDetails.errors |= UIDNA_ERROR_DOMAIN_NAME_TOO_LONG; + return true; +} + +static bool domainNameToASCII(StringView domain, Vector& ascii, UIDNAInfo& processingDetails) +{ + auto source = domain.upconvertedCharacters(); + const auto& transcoder = WTF::URLParser::internationalDomainNameTranscoder(); + UErrorCode error = U_ZERO_ERROR; + processingDetails = UIDNA_INFO_INITIALIZER; + std::array buffer; + int32_t len = uidna_nameToASCII(&transcoder, source, domain.length(), buffer.data(), buffer.size(), &processingDetails, &error); + if (error == U_BUFFER_OVERFLOW_ERROR && len > 0) { + if (!ascii.tryGrow(static_cast(len))) + return false; + error = U_ZERO_ERROR; + processingDetails = UIDNA_INFO_INITIALIZER; + len = uidna_nameToASCII(&transcoder, source, domain.length(), ascii.mutableSpan().data(), static_cast(ascii.size()), &processingDetails, &error); + if (U_SUCCESS(error) && len > 0) { + ascii.shrink(static_cast(len)); + return true; + } + ascii.clear(); + } + if (error == U_INPUT_TOO_LONG_ERROR) + return nameToASCIIFallback(transcoder, source, domain.length(), ascii, processingDetails); + if (!U_SUCCESS(error) || len <= 0) + return false; + ascii.append(std::span { buffer }.first(static_cast(len))); + return true; +} + +static bool domainNameToUnicode(StringView domain, Vector& out, UIDNAInfo& processingDetails) +{ + auto source = domain.upconvertedCharacters(); + const auto& transcoder = WTF::URLParser::internationalDomainNameTranscoder(); + UErrorCode error = U_ZERO_ERROR; + processingDetails = UIDNA_INFO_INITIALIZER; + std::array buffer; + int32_t len = uidna_nameToUnicode(&transcoder, source, domain.length(), buffer.data(), buffer.size(), &processingDetails, &error); + if (error == U_BUFFER_OVERFLOW_ERROR && len > 0) { + if (!out.tryGrow(static_cast(len))) + return false; + error = U_ZERO_ERROR; + processingDetails = UIDNA_INFO_INITIALIZER; + len = uidna_nameToUnicode(&transcoder, source, domain.length(), out.mutableSpan().data(), static_cast(out.size()), &processingDetails, &error); + if (U_SUCCESS(error) && len > 0) { + out.shrink(static_cast(len)); + return true; + } + out.clear(); + return false; + } + if (!U_SUCCESS(error) || len <= 0) + return false; + out.append(std::span { buffer }.first(static_cast(len))); + return true; +} + JSC_DEFINE_HOST_FUNCTION(jsDomainToASCII, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto& vm = JSC::getVM(globalObject); @@ -52,21 +256,16 @@ JSC_DEFINE_HOST_FUNCTION(jsDomainToASCII, (JSC::JSGlobalObject * globalObject, J if (domain.containsOnlyASCII()) 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; + Vector hostnameBuffer; 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) }))); + if (domainNameToASCII(domain, hostnameBuffer, processingDetails) + && !(processingDetails.errors & ~WTF::URLParser::allowedNameToASCIIErrors) && !hostnameBuffer.isEmpty()) { + Vector ascii; + ascii.reserveInitialCapacity(hostnameBuffer.size()); + for (auto c : hostnameBuffer) + ascii.append(static_cast(c)); + return JSC::JSValue::encode(JSC::jsString(vm, WTF::String(ascii.span()))); } return JSC::JSValue::encode(jsEmptyString(vm)); } @@ -121,22 +320,13 @@ JSC_DEFINE_HOST_FUNCTION(jsDomainToUnicode, (JSC::JSGlobalObject * globalObject, // this function is only for undoing punycode so its okay if utf-16 text makes it out unchanged. return JSC::JSValue::encode(arg0); - domain.convertTo16Bit(); - constexpr static int allowedNameToUnicodeErrors = 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 int hostnameBufferLength = 2048; - auto encoder = &WTF::URLParser::internationalDomainNameTranscoder(); - char16_t hostnameBuffer[hostnameBufferLength]; - UErrorCode error = U_ZERO_ERROR; + Vector hostnameBuffer; UIDNAInfo processingDetails = UIDNA_INFO_INITIALIZER; - - const auto span = domain.span16(); - - int32_t numCharactersConverted = uidna_nameToUnicode(encoder, span.data(), span.size(), hostnameBuffer, hostnameBufferLength, &processingDetails, &error); - - if (U_SUCCESS(error) && !(processingDetails.errors & ~allowedNameToUnicodeErrors) && numCharactersConverted) { - return JSC::JSValue::encode(JSC::jsString(vm, WTF::String(std::span { hostnameBuffer, static_cast(numCharactersConverted) }))); + if (domainNameToUnicode(domain, hostnameBuffer, processingDetails) + && !(processingDetails.errors & ~allowedNameToUnicodeErrors) && !hostnameBuffer.isEmpty()) { + return JSC::JSValue::encode(JSC::jsString(vm, WTF::String(hostnameBuffer.span()))); } return JSC::JSValue::encode(jsEmptyString(vm)); } 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 d46b30f8c2e..0431dc9b3cf 100644 --- a/test/js/node/url/url-domain-ascii-unicode.test.js +++ b/test/js/node/url/url-domain-ascii-unicode.test.js @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import punycode from "node:punycode"; import url from "node:url"; const pairs = [ @@ -99,3 +100,53 @@ describe("url.domainToUnicode", () => { }); } }); + +// UTS #46 ToASCII runs with VerifyDnsLength=false so host length must not reject. +// These cases cover ICU's ENCODE_MAX_CODE_UNITS (1000 UTF-16 units per label) +// and the former fixed 2048-unit output buffer. +describe("url.domainToASCII/domainToUnicode with long internationalised hosts", () => { + const a = n => Buffer.alloc(n, "a").toString(); + + const longLabelCases = [ + ["label just past ICU's 1000-unit cap", "\u00fc" + a(1000)], + ["1500-unit label", "\u00fc" + a(1499)], + ["long label followed by ASCII labels", "\u00fc" + a(1200) + ".example.com"], + ["long label with supplementary-plane code point", "\u{1F600}" + a(1100)], + ["long label of only BMP non-ASCII", "\u03b1\u03b2\u03b3\u03b4".repeat(300)], + ["several long labels with output over 2048 units", `${"\u00fc" + a(1000)}.`.repeat(3) + "com"], + ]; + for (const [name, input] of longLabelCases) { + test(`ToASCII encodes a ${name}`, () => { + const got = url.domainToASCII(input); + // Reference: the pure-JS RFC 3492 encoder in node:punycode has no length + // cap. UTS #46 mapping for these inputs reduces to NFC + lowercasing. + const mapped = input.normalize("NFC").toLowerCase(); + expect(got).toBe(punycode.toASCII(mapped)); + expect(got.startsWith("xn--")).toBe(true); + expect(url.domainToUnicode(got)).toBe(mapped); + }); + } + + test("ToASCII handles output longer than 2048 units (many labels)", () => { + const oneLabel = "\u00fc" + a(250); + const oneAce = punycode.toASCII(oneLabel); + const input = (oneLabel + ".").repeat(9) + "com"; + const got = url.domainToASCII(input); + expect(got).toBe((oneAce + ".").repeat(9) + "com"); + expect(got.length).toBe(2334); + expect(url.domainToUnicode(got)).toBe(input); + }); + + test("ToUnicode handles output longer than 2048 units", () => { + const oneLabel = "\u00fc" + a(250); + const oneAce = punycode.toASCII(oneLabel); + const input = (oneAce + ".").repeat(9) + "com"; + const got = url.domainToUnicode(input); + expect(got).toBe((oneLabel + ".").repeat(9) + "com"); + expect(url.domainToASCII(got)).toBe(input); + }); + + test("ToASCII still rejects disallowed code points in a long label", () => { + expect(url.domainToASCII("\u0378" + a(1100))).toBe(""); + }); +});