Skip to content

feat(net/unstable): classify IP addresses by IANA special-purpose registry - #7324

Open
tomas-zijdemans wants to merge 3 commits into
denoland:mainfrom
tomas-zijdemans:feat-net-classify-ip
Open

tomas-zijdemans wants to merge 3 commits into
denoland:mainfrom
tomas-zijdemans:feat-net-classify-ip

Conversation

@tomas-zijdemans

@tomas-zijdemans tomas-zijdemans commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Adds classifyIP() plus isLoopback(), isPrivate(), isLinkLocal(),
isMulticast() and isUnspecified(). 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.9 and
192.0.0.10 are globally reachable inside a reserved 192.0.0.0/24.
2001::/23 is reserved "unless allowed by a more specific allocation", with
seven reachable allocations inside it. A chain of ifs gets that right only if
every exception is hand-written in the right place.

IPv4-mapped addresses are unmapped before lookup, because ::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 rather than that address, so they classify by their
own block. Embedded-IPv4 extraction and isGloballyReachable() wait for a
caller who needs them. Next from me is maskIP() and formatIP(), which is
what @std/rate-limit calls to key a client by its IPv6 allocation rather than
its 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 checked
that every row is a canonical CIDR, because parseSubnet() masks host bits
silently. Three rows come from RFCs instead of the registries and are cited in
place: 224.0.0.0/4, ff00::/8, and fec0::/10, which IANA dropped when RFC
3879 deprecated site-local. I also ran the predicates against Python's
ipaddress over 16,244 addresses: loopback, link-local, multicast and
unspecified agree everywhere.

Two caller notes. url.hostname keeps IPv6 brackets, so you strip them
yourself (documented, with a runnable example). Unallocated space like
4000::/3 comes back global, the same simplification Go's IsGlobalUnicast
makes.

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

…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
@github-actions github-actions Bot added the net label Sep 17, 2026
@codecov

codecov Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.05%. Comparing base (2958335) to head (cdc58d2).

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.
📢 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.

@bartlomieju

Copy link
Copy Markdown
Member

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 classifyIP() / IPAddressKind docs that "global" means "not in a special-purpose block", not "safe to connect to". For example:

  • classifyIP("64:ff9b::a9fe:a9fe") returns "global", even though it's the NAT64 form of 169.254.169.254
  • ::ffff:0:7f00:1 (IPv4-translated 127.0.0.1) also returns "global"

#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 "reserved" covers both non-routable blocks (e.g. 0.0.0.0/8) and routable tunnel prefixes (6to4, Teredo).

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.
@tomas-zijdemans

Copy link
Copy Markdown
Contributor Author

Thanks for checking the tables. All three are in d13eb88.

  • IPAddressKind now says "global" means "not special-purpose", not "safe to connect to", and that "reserved" covers both non-routable blocks like 0.0.0.0/8 and routable tunnel prefixes (6to4, Teredo).
  • classifyIP() says a "global" result isn't SSRF protection on its own: the IPv4 inside a NAT64, 6to4, Teredo or IPv4-translated address isn't inspected, and DNS rebinding and redirects are out of scope. A doc-tested example shows 64:ff9b::a9fe:a9fe and ::ffff:0:a9fe:a9fe both return "global".
  • The tunnel test gained the IPv4-translated case.

If anyone wants the embedded address checked, an embeddedIPv4() follow-up is sketched. Parked until someone asks.

This branch has not been deployed

No deployments
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.

2 participants