Guard
NAME
MCP::Server::Tool::Web::Guard - the SSRF floor: which URLs, and which addresses behind them, the web tools will talk to
SYNOPSIS
use MCP::Server::Tool::Web::Guard;
# The default: the public internet, and nothing else.
my $guard = MCP::Server::Tool::Web::Guard.new;
# Shape first ā this throws X::ā¦::BadUrl before anything is resolved.
my $url = $guard.check-url('https://docs.example.com/guide');
# Then the address, once a socket is actually connected. $peer is what the
# socket says it is talking to, so a name cannot be re-resolved out from
# under the check.
$guard.check-address($url.host, '93.184.216.34'); # True
$guard.check-address($url.host, '10.0.0.5'); # throws X::ā¦::Blocked
# A test rig, or an operator who really does want the machine's own network:
my $lan = MCP::Server::Tool::Web::Guard.new(
allow-loopback => True, # 127.0.0.0/8 and ::1 only
allow-hosts => <wiki.corp docs.corp>,# exact host names, no wildcards
allow-cidrs => ['10.9.0.0/16'], # v4 and v6 blocks
);
$lan.check-address('wiki.corp', '10.1.2.3'); # True ā named in allow-hosts
$lan.check-address('other.corp', '10.9.1.1'); # True ā inside allow-cidrs
$lan.check-address('other.corp', '10.1.2.3'); # throws ā neither
# No allow-list entry rescues a URL that was refused on its shape.
$lan.check-url('file:///etc/passwd'); # throws X::ā¦::BadUrl
$lan.check-url('http://user:[email protected]/'); # throws X::ā¦::BadUrl
DESCRIPTION
The guard is the policy half of the SSRF floor: it holds the operator's configuration and answers two questions.
check-url ā is this URL one we would ever fetch?
Shape only: scheme, credentials, host spelling, port range. It runs before
anything is resolved, and no allow-list entry can rescue a URL it refuses.
That is a deliberate asymmetry: allow-lists exist to widen which machines
may be reached, and file:///etc/passwd is not a machine.
When the URL's host is an address literal, check-url also runs the address
check immediately, because there is nothing left to learn from resolving it ā
so http://127.0.0.1/ is refused up front, with the same message and the same
allow-list rules that would have applied after connecting.
check-address ā is the machine we just connected to one we may talk to?
Given the host that was asked for and the peer address of the socket that was actually established, the peer is classified by MCP::Server::Tool::Web::Addr and:
'public'is allowed. Nothing else is, unless the configuration says so.allow-privateallows every class. It is the blunt instrument: one switch for "this machine may reach its own network".allow-hostsallows the request if the host asked for is in the list, matched exactly.corpmatches the hostcorpand nothing else ā notwiki.corp, and emphatically notevil.com.corp. Suffix matching is where allow-list CVEs come from; there is no wildcard syntax, and a pattern in the list is a configuration error rather than a silent no-op.allow-cidrsallows the request if the peer address falls inside one of the listed blocks. IPv4 and IPv6 both, and a v4 address written in its v6-mapped form matches a v4 block (which platform you are on decides which spelling you see).allow-loopbackallows loopback only ā the narrow key for test rigs and local development servers, so that enabling one does not open the whole LAN.
An address that does not parse is refused: the guard never treats "I could not tell" as "it is fine".
Why the peer, not a lookup
check-address takes the address of an established socket. Resolving the
name a second time to check it would be a race the attacker picks the winner
of (DNS rebinding); the address of a live TCP connection cannot be changed by
a later DNS answer. The connector in MCP::Server::Tool::Web::Transport
connects first, checks second, and has written nothing at the point of the
check ā no request, no Host, not even a TLS ClientHello.
Refusals teach
Every refusal names the rule, the classification, the RFC behind it, the host, the address it resolved to, and the configuration key that would permit it. The audience is a language model deciding what to try next, and "denied" makes it try the same thing spelled differently.