feat(net/unstable): classify IP addresses by IANA special-purpose registry - #7324
tomas-zijdemans wants to merge 3 commits into
Conversation
…istry Adds `classifyIP()`, returning one of eleven kinds, with `isLoopback()`, `isPrivate()`, `isLinkLocal()`, `isMulticast()` and `isUnspecified()` as thin wrappers over it. Lookup is a longest-prefix match over a table transcribed from the two IANA special-purpose registries, so a carve-out beats the block it sits inside. `192.0.0.9` is globally reachable inside a reserved `192.0.0.0/24`, and `2001::/23` is reserved "unless allowed by a more specific allocation" with seven reachable allocations inside it. A chain of `if`s gets those wrong unless every exception is hand-written in the right order. Three blocks come from RFCs rather than the registries and are cited in place: `224.0.0.0/4`, `ff00::/8`, and `fec0::/10`, which IANA removed when RFC 3879 deprecated site-local. IPv4-mapped addresses are unmapped first, since `::ffff:127.0.0.1` is `127.0.0.1`. Tunnels are not: 6to4, NAT64, Teredo and the deprecated `::/96` are a route to an address, not that address, so they classify by their own block. Extracting their embedded IPv4 is a follow-up. I diffed every row against the registry CSVs. Every row IANA marks Globally Reachable True classifies as "global". I also compared the predicates against Python's `ipaddress` over 16,244 addresses drawn from block boundaries and random sampling, with no disagreement on loopback, link-local, multicast or unspecified. Refs denoland#7315
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #7324 +/- ##
==========================================
+ Coverage 95.04% 95.05% +0.01%
==========================================
Files 619 618 -1
Lines 52012 51866 -146
Branches 9450 9412 -38
==========================================
- Hits 49433 49300 -133
+ Misses 2031 2022 -9
+ Partials 548 544 -4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Thanks, this is carefully done. I spot-checked both tables against the IANA registries and they look right, and the longest-prefix design is a good fit. One request before landing: please make it explicit in the
#7315 lists SSRF protection as a use case. A short note would help people avoid misusing this: say that IPv4 addresses embedded in NAT64/6to4/Teredo addresses aren't inspected, and that DNS rebinding and redirects are out of scope. Minor: it would also help to document that |
Document that classifyIP() does not inspect IPv4 addresses embedded in NAT64, 6to4, Teredo or IPv4-translated addresses, and that DNS rebinding and redirects are out of scope. Also note that "reserved" spans both non-routable blocks and routable tunnel prefixes.
|
Thanks for checking the tables. All three are in d13eb88.
If anyone wants the embedded address checked, an |
Adds
classifyIP()plusisLoopback(),isPrivate(),isLinkLocal(),isMulticast()andisUnspecified(). Step 2 of #7315, on top of the parsers from#7316.
Lookup is a longest-prefix match over a table transcribed from the two IANA
special-purpose registries, and the nesting is why.
192.0.0.9and192.0.0.10are globally reachable inside a reserved192.0.0.0/24.2001::/23is reserved "unless allowed by a more specific allocation", withseven reachable allocations inside it. A chain of
ifs gets that right only ifevery exception is hand-written in the right place.
IPv4-mapped addresses are unmapped before lookup, because
::ffff:127.0.0.1is127.0.0.1. Tunnels are not: 6to4, NAT64, Teredo and the deprecated::/96are a route to an address rather than that address, so they classify by their
own block. Embedded-IPv4 extraction and
isGloballyReachable()wait for acaller who needs them. Next from me is
maskIP()andformatIP(), which iswhat
@std/rate-limitcalls to key a client by its IPv6 allocation rather thanits address.
Look hardest at the two tables. I diffed every row against the registry CSVs,
checked that every Globally-Reachable-True row comes back
global, and checkedthat every row is a canonical CIDR, because
parseSubnet()masks host bitssilently. Three rows come from RFCs instead of the registries and are cited in
place:
224.0.0.0/4,ff00::/8, andfec0::/10, which IANA dropped when RFC3879 deprecated site-local. I also ran the predicates against Python's
ipaddressover 16,244 addresses: loopback, link-local, multicast andunspecified agree everywhere.
Two caller notes.
url.hostnamekeeps IPv6 brackets, so you strip themyourself (documented, with a runnable example). Unallocated space like
4000::/3comes backglobal, the same simplification Go'sIsGlobalUnicastmakes.
I used Claude Code to help investigate and write this change.