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::Blocked if 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 :host named 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.

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.