Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
242 changes: 216 additions & 26 deletions src/jsc/bindings/NodeURL.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<const char16_t> label, Vector<char16_t>& 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<char16_t>(d + (d < 26 ? 'a' : ('0' - 26)));
};

Vector<char32_t, 256> 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<char16_t>(cp));
++handled;
}
}
size_t basic = handled;
if (basic)
out.append('-');

WTF::CheckedUint32 delta = 0;
while (handled < codePoints.size()) {
char32_t m = std::numeric_limits<char32_t>::max();
for (auto cp : codePoints) {
if (cp >= n && cp < m)
m = cp;
}
delta += WTF::CheckedUint32(m - n) * WTF::CheckedUint32(static_cast<uint32_t>(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<char32_t>(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<char16_t>& ascii, UIDNAInfo& processingDetails)
{
Vector<char16_t> 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<size_t>(len)))
return false;
error = U_ZERO_ERROR;
unicodeInfo = UIDNA_INFO_INITIALIZER;
len = uidna_nameToUnicode(&transcoder, src, srcLength, unicode.mutableSpan().data(), static_cast<int32_t>(unicode.size()), &unicodeInfo, &error);
if (U_FAILURE(error))
return false;
unicode.shrink(static_cast<size_t>(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<char16_t>& ascii, UIDNAInfo& processingDetails)
{
auto source = domain.upconvertedCharacters();
const auto& transcoder = WTF::URLParser::internationalDomainNameTranscoder();
UErrorCode error = U_ZERO_ERROR;
processingDetails = UIDNA_INFO_INITIALIZER;
std::array<char16_t, WTF::URLParser::hostnameBufferLength> 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<size_t>(len)))
return false;
error = U_ZERO_ERROR;
processingDetails = UIDNA_INFO_INITIALIZER;
len = uidna_nameToASCII(&transcoder, source, domain.length(), ascii.mutableSpan().data(), static_cast<int32_t>(ascii.size()), &processingDetails, &error);
if (U_SUCCESS(error) && len > 0) {
ascii.shrink(static_cast<size_t>(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<size_t>(len)));
return true;
}

static bool domainNameToUnicode(StringView domain, Vector<char16_t>& out, UIDNAInfo& processingDetails)
{
auto source = domain.upconvertedCharacters();
const auto& transcoder = WTF::URLParser::internationalDomainNameTranscoder();
UErrorCode error = U_ZERO_ERROR;
processingDetails = UIDNA_INFO_INITIALIZER;
std::array<char16_t, WTF::URLParser::hostnameBufferLength> 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<size_t>(len)))
return false;
error = U_ZERO_ERROR;
processingDetails = UIDNA_INFO_INITIALIZER;
len = uidna_nameToUnicode(&transcoder, source, domain.length(), out.mutableSpan().data(), static_cast<int32_t>(out.size()), &processingDetails, &error);
if (U_SUCCESS(error) && len > 0) {
out.shrink(static_cast<size_t>(len));
return true;
}
out.clear();
return false;
}
if (!U_SUCCESS(error) || len <= 0)
return false;
out.append(std::span { buffer }.first(static_cast<size_t>(len)));
return true;
}

JSC_DEFINE_HOST_FUNCTION(jsDomainToASCII, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
auto& vm = JSC::getVM(globalObject);
Expand Down Expand Up @@ -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<char16_t> 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<unsigned int>(numCharactersConverted) })));
if (domainNameToASCII(domain, hostnameBuffer, processingDetails)
&& !(processingDetails.errors & ~WTF::URLParser::allowedNameToASCIIErrors) && !hostnameBuffer.isEmpty()) {
Vector<Latin1Character> ascii;
ascii.reserveInitialCapacity(hostnameBuffer.size());
for (auto c : hostnameBuffer)
ascii.append(static_cast<Latin1Character>(c));
return JSC::JSValue::encode(JSC::jsString(vm, WTF::String(ascii.span())));
}
return JSC::JSValue::encode(jsEmptyString(vm));
}
Expand Down Expand Up @@ -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<char16_t> 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<unsigned int>(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));
}
Expand Down
51 changes: 51 additions & 0 deletions test/js/node/url/url-domain-ascii-unicode.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import punycode from "node:punycode";
import url from "node:url";

const pairs = [
Expand Down Expand Up @@ -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("");
});
});
Loading