Addr
NAME
MCP::Server::Tool::Web::Addr - IP literal parsing, unwrapping and classification
SYNOPSIS
use MCP::Server::Tool::Web::Addr;
# Parse, then ask what kind of address it is.
my $a = MCP::Server::Tool::Web::Addr.parse('10.0.0.5');
say $a.classify; # private
say $a.reference; # RFC 1918
# Anything that is not a public address is refused by the guard, and the
# class name is what the refusal teaches with.
say classify-address('8.8.8.8'); # public
say classify-address('169.254.169.254'); # metadata
say classify-address('::1'); # loopback
say classify-address('not an address'); # (Str) ā unparseable
# IPv6 forms that carry an IPv4 address inside them are unwrapped first, so
# there is no way to smuggle 127.0.0.1 past the table by spelling it in v6.
say classify-address('::ffff:127.0.0.1'); # loopback (v4-mapped)
say classify-address('::ffff:8.8.8.8'); # public (v4-mapped)
say classify-address('64:ff9b::a00:1'); # private (NAT64, 10.0.0.1)
say classify-address('2002:7f00:1::'); # loopback (6to4, 127.0.0.1)
# The allow-list helper: does this address fall inside this block?
say cidr-contains('10.0.0.0/8', '10.4.5.6'); # True
say cidr-contains('10.0.0.0/8', '11.0.0.1'); # False
say cidr-contains('10.0.0.0/8', '::ffff:10.4.5.6'); # True ā mapped form counts
say valid-cidr('10.0.0.0/33'); # False
DESCRIPTION
Pure address arithmetic: no DNS, no sockets, no clock, nothing that can fail in a way a test cannot reproduce. Everything here answers one question ā given a literal address, is it somewhere on the public internet, or somewhere on the machine (or the network) that is running the tool?
The answer is a class name, not a boolean, because the whole point of the
SSRF floor is to teach: a model that is told "refused: loopback (RFC 1122)"
can decide to stop asking for localhost, while one told "refused" tries
seventeen spellings of the same thing.
The classification
classify returns exactly one of these strings. Everything not covered by
the table is 'public':
| class what it covers reference |
| public anything not listed below ā |
| unspecified :: RFC 4291 §2.5.2 |
| this-network 0.0.0.0/8 RFC 1122 §3.2.1.3 |
| loopback 127.0.0.0/8, ::1 RFC 1122, RFC 4291 §2.5.3 |
| private 10/8, 172.16/12, 192.168/16 RFC 1918 |
| unique-local fc00::/7 RFC 4193 |
| cgnat 100.64.0.0/10 RFC 6598 |
| link-local 169.254.0.0/16, fe80::/10 RFC 3927, RFC 4291 §2.5.6 |
| metadata well-known cloud metadata endpoints (inside the ranges above) |
| ietf-protocol 192.0.0.0/24 RFC 6890 §2.1 |
| documentation 192.0.2/24, 198.51.100/24, 203.0.113/24, 2001:db8::/32 RFC 5737, RFC 3849 |
| 6to4-relay 192.88.99.0/24 RFC 7526 |
| benchmark 198.18.0.0/15 RFC 2544 |
| multicast 224.0.0.0/4, ff00::/8 RFC 5771, RFC 4291 §2.7 |
| reserved 240.0.0.0/4 (incl. 255.255.255.255) RFC 1112 §4 |
| discard 100::/64 RFC 6666 |
| teredo 2001::/32 RFC 4380 |
| segment-routing 5f00::/16 RFC 9602 |
| nat64-local 64:ff9b:1::/48 that carries no RFC 6052 address RFC 8215 |
metadata is mostly a refinement rather than an extra denial: nearly every
address it names sits inside a range that is denied anyway (169.254.169.254
is link-local, 100.100.100.200 is CGNAT, 192.0.0.192 is IETF-protocol,
fd00:ec2::254 is unique-local). It exists because "cloud instance metadata
endpoint" is a far more useful thing to tell a model ā or an operator reading
a log ā than "link-local". The one genuine extra denial is Azure's
wireserver, 168.63.129.16: it sits in public address space but is routed
only inside an Azure VNet, so nothing on the public web legitimately serves
from it and denying the single address closes a well-known SSRF target.
Unwrapping
IPv6 has five ways to write an IPv4 address, and every one of them is a way
to spell 127.0.0.1 that a naive table misses. unwrap maps them all back
to the address they carry, and classify unwraps before it looks anything
up:
::ffff:0:0/96ā IPv4-mapped (RFC 4291 §2.5.5.2). What a dual-stack socket reports for a v4 peer on some platforms, so this is the common case, not the exotic one.::/96ā IPv4-compatible (deprecated, RFC 4291 §2.5.5.1), except::and::1themselves, which keep their own meanings.64:ff9b::/96ā the well-known NAT64 prefix (RFC 6052 §2.1).64:ff9b:1::/48ā local-use NAT64 (RFC 8215) with the RFC 6052 §2.2 /48 embedding (two octets, the zero u-octet, two more octets). An address in that prefix whose u-octet is not zero carries no address we can read, so it fails closed asnat64-localrather than being waved through as public.2002::/16ā 6to4 (RFC 3056), the embedded address being the next 32 bits.
Unwrapping is one level deep by construction: every wrapper yields an IPv4 address, and IPv4 has no wrappers.
Strictness
parse takes canonical literals only. 0177.0.0.1, 127.1, 0x7f000001
and 2130706433 are not addresses here ā they are refused, and
MCP::Server::Tool::Web::Url refuses URLs that carry them, so neither an
operator nor a model ever has to reason about which radix a host name is in.
Zone identifiers (fe80::1%eth0) are refused for the same reason: the guard
would have nothing meaningful to say about the scope.