Skip to content

fix(net/unstable): parse IP addresses per the URL standard - #7316

Open
tomas-zijdemans wants to merge 3 commits into
denoland:mainfrom
tomas-zijdemans:fix-net-ip-parsers
Open

fix(net/unstable): parse IP addresses per the URL standard#7316
tomas-zijdemans wants to merge 3 commits into
denoland:mainfrom
tomas-zijdemans:fix-net-ip-parsers

Conversation

@tomas-zijdemans

@tomas-zijdemans tomas-zijdemans commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

First step of #7315. Adds parseIPv4() and parseIPv6(), which return the
address bytes or undefined, and rebuilds isIPv4(), isIPv6() and the three
subnet matchers on top of them. This follows through on the parser design
0f-0b asked for
in #6765.

Problem

isIPv4() split on . and passed each part to Number(), which happily reads
hex, exponents, signs, leading zeros, whitespace and the empty string.
isIPv6() expanded :: by hand and never checked that colons sat where the
grammar wants them.

Input isIPv4 should be
" 1.2.3.4", "1.2.3.4 " true false
"01.002.3.4" true false
"0x7f.0.0.1" true false
"1e2.1.1.1" true false
"+1.2.3.4" true false
"1.2.3.", "1..2.3" true false
Input isIPv6 should be
":1" true false
"2001:db8:::1" true false
"::1 " true false
"::ffff:0x1.2.3.4" true false
"fe80::1%eth0" true false, zone IDs are out of scope

matchSubnets() gated on a private isValidIP() built from the same two
functions, so every one of those reached the matchers.

Change

parseIPv4() implements the URL standard's
valid IPv4-address string:
four decimal octets, shortest spelling, no leading zeros. That last rule is what
stops 010.0.0.1 from being read as octal, a parser differential with a long
CVE history. Go, Rust and Python all reject it too.

parseIPv6() is a transcription of the URL standard's
IPv6 parser, including the
trailing x:x:x:x:x:x:d.d.d.d form. Zone IDs are now rejected, which the
standard calls out as deliberate. Worth knowing that Deno's own node:net polyfill
accepts them, so isIPv6() and node:net's isIP() now disagree on
fe80::1%eth0.

isIPv4() and isIPv6() become parseX() !== undefined. The matchers parse
both sides into bytes and compare the leading prefix bits, so the private
expandIPv6() and ipv6ToBytes() are gone.

This is the strict grammar from the open question at the bottom of #7315. If the
answer comes back "permissive", parseIPv4() is the only thing that changes.

One more tightening, in subnet prefixes

Prefix lengths used to go through parseInt(s, 10), so /0x18 parsed as 0
and matched every address. An allowlist built on that let everything through.
They are now validated as decimal digits in range, which also rejects /+24,
/ 24, /24abc and /24/8, all of which used to quietly mean 24, and /1e1,
which meant 1.

Zero-padded lengths still work. /024 has only one reading, unlike an octet, so
there was no reason to break configs that spell it that way.

Semver

fix, not BREAKING. The module is unstable, and the behavior changes go in
both directions, neither of them breaking.

Almost everything that moves is an input that was invalid all along and is now
rejected. Calling that out since it is a wide change: anything relying on
isIPv4(" 1.2.3.4") being true will notice.

Two changes go the other way, and both are false negatives fixed:
1:2:3:4:5:6:7:: and ::1:2:3:4:5:6:7 are valid, and the old isIPv6()
rejected them. A leading or trailing :: makes split(":") return 9 elements,
so the expansion loop never ran and the length check failed. I enumerated every
:: position over 0 to 9 groups, with and without an IPv4 tail: those two are
the only valid addresses the old implementation turned away.

Validation

env -u NO_COLOR deno task ok on Deno 2.9.6: 7,450 tests pass. One unrelated
failure, writeTextFile() handles an AbortSignal in fs/, which is a temp-dir
cleanup race under --parallel and passes three times out of three on its own.
The net suite is 25 tests including doc examples, green every run.

Both parsers were differential-tested against the runtime's own WHATWG URL
parser, which implements the same two algorithms:

  • IPv6, ~49,500 generated inputs against new URL("http://[" + s + "]/"): no
    disagreement on validity. For the 2,716 that parse, I serialized the bytes
    back through a WHATWG IPv6 serializer and compared against URL.hostname.
    Byte-exact on all of them.
  • IPv4, 390,625 four-part all-digit inputs against a URL.hostname round-trip:
    no disagreement. The corpus has to be restricted that way, because 0.0.0.a
    round-trips as an opaque domain rather than an address, and an unrestricted
    oracle reports that as a parser bug.
  • Subnet prefixes, every all-digit spelling from 0 to 40 bits (IPv4) and 0 to
    136 (IPv6) with up to three leading zeros, 3,560 comparisons against the base
    commit: no behavior change.

Tests cover the tables above, the two fixed false negatives, plus
1:2:3:4:5:6:7::8, 1:2:3:4:5:6:7:8:9, ::ffff:1.2.3, uppercase hex, both
mapped forms, and bit boundaries either side of /25, /33 and /121. Every
row was checked against the base commit, so none of them pass for the wrong
reason.

I used Claude Code to help investigate and write this change.

`isIPv4()` was a `split(".")` plus `Number()` per part, which accepts
whitespace, leading zeros, hex, exponent and sign forms, and empty
octets. `isIPv6()` expanded `::` by hand and accepted a leading lone
`":"` and repeated `"::"`.

Adds `parseIPv4()` and `parseIPv6()`, returning the address bytes or
`undefined`, and reduces `isIPv4()`/`isIPv6()` to `parseX() !== undefined`.
`parseIPv4()` implements the URL standard's valid IPv4-address string
grammar (strict dotted quad, shortest decimal spelling). `parseIPv6()`
implements the URL standard's IPv6 parser, which omits zone IDs.

`matchSubnets()`, `matchIPv4Subnet()` and `matchIPv6Subnet()` now go
through the parsers, so `expandIPv6()` and `ipv6ToBytes()` are gone.
CIDR prefix lengths are now validated as decimal digits within range
rather than handed to `parseInt()`, which read `/0x18` as 0 and so
matched every address. Zero-padded lengths such as `/024` keep working:
unlike an address octet they have only one reading.

Inputs accepted before and rejected now:

| Input | was | now |
|---|---|---|
| `" 1.2.3.4"`, `"1.2.3.4 "` | true | false |
| `"01.002.3.4"` | true | false |
| `"0x7f.0.0.1"` | true | false |
| `"1e2.1.1.1"` | true | false |
| `"+1.2.3.4"` | true | false |
| `"1.2.3."`, `"1..2.3"` | true | false |
| `":1"` | true | false |
| `"2001:db8:::1"` | true | false |
| `"::1 "` | true | false |
| `"::ffff:0x1.2.3.4"` | true | false |
| `"fe80::1%eth0"` | true | false |

Plus, in subnet prefix lengths, the `parseInt()` leniencies: `/0x18`,
`/+24`, `/ 24`, `/24abc`, `/1e1`, `/24/8`.

Every behavior change is an invalid input that is no longer accepted, so
this is `fix` rather than `BREAKING`.

Towards denoland#7315.
@github-actions github-actions Bot added the net label Sep 12, 2026
@codecov

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.41176% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 95.03%. Comparing base (ca58f94) to head (e65facf).

Files with missing lines Patch % Lines
net/unstable_ip.ts 99.41% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7316      +/-   ##
==========================================
- Coverage   95.03%   95.03%   -0.01%     
==========================================
  Files         617      617              
  Lines       51637    51636       -1     
  Branches     9359     9365       +6     
==========================================
- Hits        49075    49073       -2     
  Misses       2021     2021              
- Partials      541      542       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The `/ 24`, `/+24` and `/1e1` rows checked `1.2.3.4` against
`192.168.1.0`, which is outside the subnet under any prefix length those
spellings could produce. They passed on the base implementation too, so
they proved nothing. They now use `192.168.1.1`, which the base
implementation does match: `parseInt()` read `/1e1` as 1 and the rest as
24. Adds the same row for `/0x18`, which meant 0.

Also adds `1:2:3:4:5:6:7::` and `::1:2:3:4:5:6:7` as positive cases. The
old `isIPv6()` rejected both: a leading or trailing `::` makes
`split(":")` return 9 elements, so the `while (hextets.length < 8)`
expansion never ran and the length check failed. Enumerating every
`::` position over 0 to 9 groups, with and without an IPv4 tail, these
two are the only valid addresses the old implementation rejected.
Two of the three guards in the IPv4-tail branch of parseIPv6() had no
test on their reject side. Both are load-bearing:

- pieceIndex > 6: the pieceIndex !== 8 backstop only runs when there is
  no compress, so with a "::" present this guard is the only thing
  rejecting an over-long tail. Without it "1:2:3:4:5:6::1.2.3.4" parses
  as valid, because the ninth write falls off the end of the Uint16Array
  and is dropped.
- !isDigit: without it "::1.2.3." and "::1..2.3" are accepted.

Each row was checked against a build with its guard removed, so none of
them pass for the wrong reason.

The third partial, the length === 0 check, is left uncovered on purpose.
No input can discriminate it: when length is 0 the rewind is a no-op and
numbersSeen is 0, so the dot is not consumed and the !isDigit check
rejects on the same character. It is kept for fidelity to the URL
standard's algorithm, where it is equally redundant.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant