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
51 changes: 51 additions & 0 deletions 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 <wtf/text/StringBuilder.h>
#include <wtf/text/StringToIntegerConversion.h>

namespace WebCore {
Expand Down Expand Up @@ -104,8 +105,54 @@ static unsigned countASCIIDigits(StringView string)
return length;
}

static inline bool isASCIITabOrNewline(char32_t c)
{
return c == 0x0009 || c == 0x000A || c == 0x000D;
}

// WTF::URL::setHost IDNA-encodes non-ASCII input without the spec's tab/newline strip or
// percent-decode. Strip tab/newline and percent-encode non-ASCII so it takes the ASCII
// passthrough and the full URLParser (which does both) runs on reparse.
static String preprocessHostSetterValue(StringView value)
{
bool needsWork = false;
for (auto codePoint : value.codePoints()) {
if (!isASCII(codePoint) || isASCIITabOrNewline(codePoint)) {
needsWork = true;
break;
}
}
if (!needsWork)
return {};

StringBuilder builder;
builder.reserveCapacity(value.length());
for (auto codePoint : value.codePoints()) {
if (isASCIITabOrNewline(codePoint))
continue;
if (isASCII(codePoint)) {
builder.append(static_cast<Latin1Character>(codePoint));
continue;
}
uint8_t utf8[U8_MAX_LENGTH];
unsigned utf8Length = 0;
// Input is IDLUSVString, so no unpaired surrogates.
U8_APPEND_UNSAFE(utf8, utf8Length, codePoint);
for (unsigned i = 0; i < utf8Length; ++i) {
builder.append('%');
builder.append(static_cast<Latin1Character>(upperNibbleToASCIIHexDigit(utf8[i])));
builder.append(static_cast<Latin1Character>(lowerNibbleToASCIIHexDigit(utf8[i])));
}
}
return builder.toString();
}

void URLDecomposition::setHost(StringView value)
{
String preprocessed = preprocessHostSetterValue(value);
if (!preprocessed.isNull())
value = preprocessed;

auto fullURL = this->fullURL();
if (value.isEmpty() && !fullURL.protocolIsFile() && fullURL.hasSpecialScheme())
return;
Expand Down Expand Up @@ -147,6 +194,10 @@ String URLDecomposition::hostname() const

void URLDecomposition::setHostname(StringView host)
{
String preprocessed = preprocessHostSetterValue(host);
if (!preprocessed.isNull())
host = preprocessed;

auto fullURL = this->fullURL();
if (host.isEmpty() && !fullURL.protocolIsFile() && fullURL.hasSpecialScheme())
return;
Expand Down
56 changes: 56 additions & 0 deletions test/js/web/url/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,62 @@ describe("url", () => {
});
});

// The host/hostname setter runs the basic URL parse with state override: it must strip
// ASCII tab/newline and percent-decode before domain-to-ASCII, exactly like the constructor.
describe("host/hostname setter matches constructor on IDNA input", () => {
const setHostname = (v: string) => {
const u = new URL("http://base9.example/a");
u.hostname = v;
return u.hostname;
};
const setHost = (v: string) => {
const u = new URL("http://base9.example/a");
u.host = v;
return u.host;
};
const ctor = (v: string) => new URL(`http://${v}/`).hostname;

it.each([
// tab/LF/CR in non-ASCII input must be stripped before IDNA, not punycode-encoded
["\tß.de", "xn--zca.de"],
["ü\t.de", "xn--tda.de"],
["ß\n.de", "xn--zca.de"],
["ß\r.de", "xn--zca.de"],
// pure-ASCII input was already stripped correctly
["a\tb.com", "ab.com"],
// percent-escapes must be decoded before domain-to-ASCII
["ü%41.de", "xn--a-dha.de"],
// mixed percent-escape and raw non-ASCII must not be silently dropped
["%C3%BCü.de", "xn--tdaa.de"],
// astral plane (surrogate pair) code point with tab
["\t\u{10348}.com", "xn--2c8c.com"],
])("hostname/host setter with %j", (input, expected) => {
expect({
ctor: ctor(input),
hostname: setHostname(input),
host: setHost(input),
}).toEqual({ ctor: expected, hostname: expected, host: expected });
});

it("host setter with non-ASCII + port", () => {
const u = new URL("http://base9.example/a");
u.host = "\tß.de:8080";
expect({ hostname: u.hostname, port: u.port }).toEqual({ hostname: "xn--zca.de", port: "8080" });
});

it("host setter with non-ASCII + default port", () => {
const u = new URL("http://base9.example/a");
u.host = "ü%41.de:80";
expect({ hostname: u.hostname, port: u.port }).toEqual({ hostname: "xn--a-dha.de", port: "" });
});

it("hostname setter with only tab/newline leaves special-scheme host unchanged", () => {
const u = new URL("http://base9.example/a");
u.hostname = "\t\n\r";
expect(u.hostname).toBe("base9.example");
});
});

// Web IDL record conversion interleaves Get with value conversion: mutations made by a
// value's toString() are observed by the keys that follow it. Node agrees.
it("URLSearchParams constructed from an object interleaves Get with value conversion", () => {
Expand Down
Loading