Parse IPv4 hosts in special-scheme URLs like WHATWG URL

The URL host parser kept a host whose last label is a number as a
domain, so new URL("http://127.1").hostname was "127.1" where Node
answers 127.0.0.1, and hosts such as 1.2.3.256, 0x100000000 and
1.2.3.4.5 parsed where Node throws Invalid URL.

A special-scheme host (file: included) that ends in a number now goes
through the WHATWG IPv4 parser: up to four labels after one trailing
empty label, each a decimal, 0x hexadecimal or leading-zero octal
number, every label but the last at most 255 and the last below
256^(5 - labels). The address serializes as dotted decimal, and any
failure makes the URL invalid. The constructor, URL.parse, URL.canParse,
the host and hostname setters and domainToASCII/domainToUnicode share
the parser. Non-special schemes keep their opaque host as written.
Corpus 5860 covers each form.
This commit is contained in:
Chris Tate
2026-10-16 20:27:21 -05:00
parent 2ab9055d76
commit b18a28bdef
3 changed files with 157 additions and 1 deletions
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5860-url-ipv4-hosts.ts"
],
"diags": []
}
+84 -1
View File
@@ -322,8 +322,80 @@ static ScrStr *parse_rooted_path(const char *raw, size_t len, bool special) {
return parse_rooted_path_mode(raw, len, special, false);
}
/* The WHATWG IPv4 number parser: "0x"/"0X" makes the rest hexadecimal
* and a leading "0" octal; an empty rest is 0. Values past 2^32 saturate
* there, since every caller rejects them. */
static bool url_ipv4_number(const char *s, size_t n, uint64_t *out) {
if (n == 0) return false;
unsigned radix = 10;
if (n >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) {
radix = 16;
s += 2;
n -= 2;
} else if (n >= 2 && s[0] == '0') {
radix = 8;
s += 1;
n -= 1;
}
uint64_t v = 0;
for (size_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)s[i];
unsigned d = c >= '0' && c <= '9' ? (unsigned)(c - '0')
: c >= 'a' && c <= 'f' ? (unsigned)(c - 'a' + 10)
: c >= 'A' && c <= 'F' ? (unsigned)(c - 'A' + 10)
: 16;
if (d >= radix) return false;
v = v > 0xFFFFFFFFull ? v : v * radix + d;
}
*out = v;
return true;
}
/* The WHATWG "ends in a number" checker: the last dot-separated label
* (one trailing empty label dropped) is all digits or an IPv4 number. */
static bool url_ends_in_number(const char *s, size_t n) {
if (n > 0 && s[n - 1] == '.') n--;
size_t start = n;
while (start > 0 && s[start - 1] != '.') start--;
const char *last = s + start;
size_t len = n - start;
if (len == 0) return false;
bool digits = true;
for (size_t i = 0; i < len && digits; i++) digits = last[i] >= '0' && last[i] <= '9';
uint64_t ignored;
return digits || url_ipv4_number(last, len, &ignored);
}
/* The WHATWG IPv4 parser: up to four labels (one trailing empty label
* dropped); every label but the last at most 255, and the last below
* 256^(5 - labels). Writes the dotted-decimal serialization. */
static bool url_ipv4_parse(const char *s, size_t n, UrlBuf *out) {
if (n > 0 && s[n - 1] == '.') n--;
uint64_t parts[4];
size_t count = 0, start = 0;
for (size_t i = 0; i <= n; i++) {
if (i < n && s[i] != '.') continue;
if (count == 4 || !url_ipv4_number(s + start, i - start, &parts[count])) return false;
count++;
start = i + 1;
}
for (size_t i = 0; i + 1 < count; i++)
if (parts[i] > 255) return false;
uint64_t limit = 1ull << (8 * (5 - count));
if (parts[count - 1] >= limit) return false;
uint64_t v = parts[count - 1];
for (size_t i = 0; i + 1 < count; i++) v += parts[i] << (8 * (3 - i));
char text[16];
int len = snprintf(text, sizeof text, "%u.%u.%u.%u", (unsigned)(v >> 24) & 255, (unsigned)(v >> 16) & 255,
(unsigned)(v >> 8) & 255, (unsigned)v & 255);
ub_append(out, text, (size_t)len);
return true;
}
/* Shared constructor/setter host parsing keeps the existing ASCII-host
* boundary and canonical IPv6 serialization in both execution tiers. */
* boundary and canonical IPv6 serialization in both execution tiers. A
* special host that ends in a number is an IPv4 address: it serializes
* as dotted decimal, or the URL is invalid. */
static bool parse_host(const char *hp, size_t host_len, bool special, bool is_file,
ScrStr **host) {
bool ipv6 = host_len > 0 && hp[0] == '[';
@@ -353,6 +425,17 @@ static bool parse_host(const char *hp, size_t host_len, bool special, bool is_fi
ub_push(&hb, (char)c);
}
}
if (!ipv6 && special && url_ends_in_number(hb.data, hb.len)) {
UrlBuf ip;
ub_init(&ip);
bool ok = url_ipv4_parse(hb.data, hb.len, &ip);
free(hb.data);
if (!ok) {
free(ip.data);
return false;
}
hb = ip;
}
/* file: "localhost" normalizes to "" at parse time (Node). */
if (is_file && hb.len == 9 && memcmp(hb.data, "localhost", 9) == 0) hb.len = 0;
if (hb.len == 0 && special && !is_file) {
+67
View File
@@ -0,0 +1,67 @@
// WHATWG IPv4 host parsing in special-scheme URLs: a host whose last label
// is a number is an IPv4 address in dotted, short, hexadecimal or octal
// form and serializes as dotted decimal; out-of-range parts and more than
// four labels make the URL invalid. Hosts that do not end in a number
// stay domains, and non-special schemes keep their host as written.
import * as nodeUrl from "node:url";
const hosts = [
"1.2.3.4",
"127.1",
"127.0.1",
"0x7f.0.0.1",
"0X7F.1",
"0177.0.0.1",
"00177.0.0.1",
"2130706433",
"0x7f000001",
"017700000001",
"0",
"0x",
"1.2.3.4.",
"1.0x.0.",
"1.2.3.256",
"1.256.3",
"1.2.65536",
"4294967295",
"4294967296",
"0x100000000",
"1.2.3.4.5",
"1.2.3.09",
"1.2.3.0xg",
"foo.0x",
"foo.1",
"1.foo",
"example.com",
"0x1.example",
"999999999999999999999",
];
for (const host of hosts) {
const special = URL.parse(`http://${host}:8080/p`);
const file = URL.parse(`file://${host}/p`);
const opaque = URL.parse(`git://${host}/p`);
console.log(
JSON.stringify(host),
special ? special.host : "invalid",
file ? JSON.stringify(file.hostname) : "invalid",
opaque ? opaque.hostname : "invalid",
);
}
try {
new URL("https://1.2.3.256/");
} catch (err) {
console.log((err as Error).name, (err as NodeJS.ErrnoException).code, (err as Error).message);
}
const url = new URL("http://example.com/");
url.hostname = "0x7f.1";
console.log(url.href);
url.host = "1.2.3.999:81";
console.log(url.href);
url.host = "10.1:82";
console.log(url.href);
console.log(new URL("ws://192.168.1/").href, new URL("https://0300.0250.1.1/").origin);
// The same host parser behind domainToASCII, domainToUnicode and URL.canParse.
console.log(nodeUrl.domainToASCII("127.1"), nodeUrl.domainToASCII("0x7f.1"), JSON.stringify(nodeUrl.domainToASCII("1.2.3.256")));
console.log(nodeUrl.domainToUnicode("127.1"), JSON.stringify(nodeUrl.domainToUnicode("1.2.3.256")));
console.log(URL.canParse("http://127.1"), URL.canParse("http://1.2.3.256"), URL.canParse("git://1.2.3.256"));
console.log(nodeUrl.format(new URL("http://0x7f.1/a")));