Transport
NAME
MCP::Server::Tool::Web::Transport - a Cro HTTP client that checks the address of every socket it opens before it writes a byte to it
SYNOPSIS
use MCP::Server::Tool::Web::Guard;
use MCP::Server::Tool::Web::Transport;
my $transport = MCP::Server::Tool::Web::Transport.new(
guard => MCP::Server::Tool::Web::Guard.new,
);
# An ordinary Cro::HTTP::Client ā except that the connection under it was
# vetted after connecting and before anything was sent.
my $resp = await $transport.client.get('https://example.com/', follow => False);
say $resp.status;
# A host that resolves onto this machine's own network is refused with the
# address it actually resolved to, and the key that would permit it.
{
await $transport.client.get('http://localhost:8080/');
CATCH {
when X::MCP::Server::Tool::Web::Blocked {
say .address-class; # loopback
say .message; # ... To permit it, set allow-loopback ...
}
}
}
Test rigs inject the one thing that talks to the network ā hostnames stay real, sockets go wherever the rig says:
my %ports = 'example.test' => 39662, 'other.test' => 39663;
my $rig = MCP::Server::Tool::Web::Transport.new(
guard => MCP::Server::Tool::Web::Guard.new(:allow-loopback),
connect-tcp => -> Str $host, Int $port {
IO::Socket::Async.connect('127.0.0.1', %ports{$host} // $port);
},
# The origin's self-signed certificate, trusted for this rig only.
ca-file => 'resources/test-cert.pem',
);
# The origin sees 'Host: example.test' and the TLS handshake carries
# example.test as its SNI name; the socket went to loopback.
await $rig.client.get('https://example.test/page');
DESCRIPTION
This is the SSRF floor's enforcement half ā MCP::Server::Tool::Web::Guard decides, and this class makes sure the decision is taken about the connection that is actually going to be used.
Cro::HTTP::Client.choose-connector is a documented override point (Cro's
own testing module uses it). This class subclasses the client and returns a
connector that:
connects with plain TCP, even for https;
reads
.peer-hostā the address of the socket it is holding;asks the guard about that address, and closes the socket and throws
X::MCP::Server::Tool::Web::Blockedif the answer is no;sets
TCP_NODELAY, exactly as Cro's own connectors do;and only then, for https, upgrades the vetted socket to TLS with
IO::Socket::Async::SSL.upgrade-client($sock, :host($host))ā the:hostnamed argument sets both the SNI name and the name the certificate is verified against, independently of what was connected to.
Why this kills DNS rebinding rather than narrowing it
The address that is checked is the address of the established connection.
A name cannot be re-resolved out from under a live TCP socket, so there is no
window between the check and the use: the usual "resolve, check, connect"
shape has one, and an attacker who controls a DNS zone with a one-second TTL
owns it. At the point of the check nothing has been written ā no request
line, no Host header, not even a TLS ClientHello ā so a refused connection
is a TCP handshake and an immediate reset, and the peer learns nothing about
what was going to be asked for.
Numeric obfuscations come out in the wash: 0x7f000001, 017700000001,
127.1 and 2130706433 are all canonicalised by the resolver, and the
socket reports 127.0.0.1 however the host was spelled. (They are refused
earlier, on shape, by MCP::Server::Tool::Web::Url ā but the floor does not
depend on that.)
Connection reuse is a security property here, not a risk
The client is built with persistent => True, and that is deliberate: a
cached pipeline is an already vetted, still established socket. Nothing can
retarget it, because retargeting a name has no effect on an open connection.
Turning persistence off would mean re-connecting ā and re-checking ā more
often, which is not safer, only slower.
The consequence to respect is the other way round: one client belongs to one guard configuration. Never share a Transport between packs configured with different allow-lists, because the second pack would inherit connections the first one's rules approved. One Transport per Guard, one Guard per pack.
The proxy hazard
Cro::HTTP::Client reads HTTP_PROXY / HTTPS_PROXY from the environment
even for client instances, and connects to the proxy instead of the URL's
host. The floor still holds ā whatever the client is told to connect to is
what gets vetted, so an internal proxy is refused like any other internal
address ā but "refused: 10.0.0.1 is private" is a baffling answer to a request
for https://example.com/. So the environment is checked up front by
check-proxy and the refusal names the variables, while
proxy => 'http://proxy.corp:3128' is available for operators who mean
it (its host is then guard-subject like any other). NO_PROXY is honoured
exactly as Cro honours it, so a host the environment already exempts is not
refused.
HTTP/1.1 only
The client is pinned to HTTP/1.1. ALPN negotiation would let a gateway steer
us onto Cro's HTTP/2 client, whose failure modes here are silent hangs; a
documentation page is not worth that. The connector still exposes
alpn-result, because Cro's version-conditional pipeline asks for it when
the version is not pinned, and a connector that answers it is one less
thing to remember if that pin ever moves.