Skip to content
Closed
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
61 changes: 61 additions & 0 deletions src/jsc/bindings/BunIDNA.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#include "root.h"
#include "BunIDNA.h"

#include <unicode/uidna.h>
#include <wtf/URL.h>
#include <wtf/URLParser.h>
#include <wtf/Vector.h>
#include <wtf/text/StringView.h>
#include <wtf/text/WTFString.h>

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<unsigned>(dot) + 1;
}
}

WTF::String domainToASCII(WTF::StringView domain)
{
std::array<char16_t, WTF::URLParser::hostnameBufferLength> stackBuffer;
WTF::Vector<char16_t> heapBuffer;
std::span<char16_t> 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<int32_t>(buffer.size()), &processingDetails, &error);
if (U_SUCCESS(error) && !(processingDetails.errors & ~WTF::URLParser::allowedNameToASCIIErrors) && length > 0)
return WTF::String { buffer.first(static_cast<size_t>(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<size_t>(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();
}
Comment thread
robobun marked this conversation as resolved.

} // namespace Bun
24 changes: 24 additions & 0 deletions src/jsc/bindings/BunIDNA.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#pragma once

#include "root.h"

#include <wtf/Forward.h>

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
29 changes: 20 additions & 9 deletions src/jsc/bindings/DOMURL.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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<uint16_t>(std::min<size_t>(m_url.string().impl()->costDuringGC(), std::numeric_limits<uint16_t>::max())))
Expand All @@ -65,24 +74,25 @@ inline DOMURL::DOMURL(URL&& completeURL)
ExceptionOr<Ref<DOMURL>> 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<Ref<DOMURL>> 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)));
}

ExceptionOr<Ref<DOMURL>> 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);
}
Expand All @@ -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> DOMURL::parse(const String& url, const String& base)
Expand All @@ -113,10 +126,8 @@ bool DOMURL::canParse(const String& url, const String& base)
ExceptionOr<void> 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();
Expand Down
25 changes: 8 additions & 17 deletions src/jsc/bindings/NodeURL.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include "NodeURL.h"
#include "BunIDNA.h"
#include "wtf/URLParser.h"
#include <unicode/uidna.h>

Expand Down Expand Up @@ -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<unsigned int>(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))
Expand Down
5 changes: 4 additions & 1 deletion src/jsc/bindings/URLDecomposition.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

#include "URLDecomposition.h"

#include "BunIDNA.h"
#include <wtf/text/StringToIntegerConversion.h>

namespace WebCore {
Expand All @@ -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();
}
Expand Down
24 changes: 24 additions & 0 deletions test/js/node/url/url-domain-ascii-unicode.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
92 changes: 92 additions & 0 deletions test/js/web/url/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading