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-private allows every class. It is the blunt instrument: one switch for "this machine may reach its own network".

  • allow-hosts allows the request if the host asked for is in the list, matched exactly. corp matches the host corp and nothing else — not wiki.corp, and emphatically not evil.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-cidrs allows 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-loopback allows 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.

MCP::Server::Tool::Web v0.1.1

web search, fetch, crawl and grep for MCP::Server

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

MCP::Server:auth<zef:apogee>:ver<0.6.0+>Cro::HTTP:auth<zef:cro>:ver<0.8.11+>Cro::Core:auth<zef:cro>:ver<0.8.10+>IO::Socket::Async::SSL:auth<zef:raku-community-modules>:ver<0.8.2+>JSON::Fast:ver<0.19>:auth<cpan:TIMOTIMO>

Test Dependencies

Provides

  • MCP::Server::Tool::Web
  • MCP::Server::Tool::Web::Addr
  • MCP::Server::Tool::Web::Budget
  • MCP::Server::Tool::Web::Crawl
  • MCP::Server::Tool::Web::Extract
  • MCP::Server::Tool::Web::Fetcher
  • MCP::Server::Tool::Web::Guard
  • MCP::Server::Tool::Web::Provider::Brave
  • MCP::Server::Tool::Web::Robots
  • MCP::Server::Tool::Web::SearchProvider
  • MCP::Server::Tool::Web::Transport
  • MCP::Server::Tool::Web::Url
  • MCP::Server::Tool::Web::X

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.