From b18a28bdefdf35d3824abb226effb6dae0b59eb2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 16 Oct 2026 20:27:21 -0500 Subject: [PATCH] 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. --- .../tests/corpus/5860-url-ipv4-hosts.ts.json | 6 ++ packages/runtime/src/scr_url.c | 85 ++++++++++++++++++- tests/corpus/5860-url-ipv4-hosts.ts | 67 +++++++++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5860-url-ipv4-hosts.ts.json create mode 100644 tests/corpus/5860-url-ipv4-hosts.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5860-url-ipv4-hosts.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5860-url-ipv4-hosts.ts.json new file mode 100644 index 000000000..418646e05 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5860-url-ipv4-hosts.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5860-url-ipv4-hosts.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_url.c b/packages/runtime/src/scr_url.c index 28daf0f8d..b6fdb9cd6 100644 --- a/packages/runtime/src/scr_url.c +++ b/packages/runtime/src/scr_url.c @@ -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) { diff --git a/tests/corpus/5860-url-ipv4-hosts.ts b/tests/corpus/5860-url-ipv4-hosts.ts new file mode 100644 index 000000000..574de9438 --- /dev/null +++ b/tests/corpus/5860-url-ipv4-hosts.ts @@ -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")));