Date: 2026-05-17 Status: Accepted
mcp-loadtest drives operator-supplied URLs over the http / sse / ws
transports. ADR 0007 ("Transport security posture") established the redirect
posture (reqwest::redirect::Policy::none() so a server cannot bounce us into
169.254.169.254 or a localhost-bound admin endpoint) and frame caps, but it
explicitly deferred a host-allowlist as an open item — at the time the tool
was treated as a single-operator local utility, and the redirect block closed
the most obvious server-driven SSRF vector.
We are about to publish v0.1.0 to crates.io. Once published, the tool is no
longer "just my local script": configs get shared, pasted into CI, and run
against URLs the operator did not personally vet. A malicious or mistaken
url = "http://169.254.169.254/latest/meta-data/" (or http://127.0.0.1:<admin port>/) in a [server] block should not silently exfiltrate cloud-instance
credentials or poke internal services. The redirect block does not help when
the initial URL is already internal. This is the right moment to add the
allowlist: doing it now folds the (breaking) connect signature change into
the initial release for free, with no released version to break.
Add a transport-layer guard (protocol::transport::guard::HostGuard) that runs
before any socket is opened — every transport parses its URL, then calls
HostGuard::check_url before building the reqwest client / dialing the WS:
- Opt-in exact-match
[server].allowed_hosts. A new#[serde(default)] allowed_hosts: Vec<String>onServerConfig(#[non_exhaustive]+ serde-default ⇒ old configs still parse, no breaking change to the config schema). Matching is exact and ASCII-case-insensitive with no wildcard / suffix logic, soapi.example.com.attacker.comcan never satisfy an entry ofapi.example.com. Entries must be bare hostnames (no scheme / port / path / whitespace);config::validaterejects malformed entries up front with an actionable message. - Always-on private/loopback/link-local/ULA/reserved IP-literal block. If
the URL host is an IP literal in 127/8, 10/8, 172.16/12, 192.168/16,
169.254/16,
0.0.0.0,255.255.255.255,::1,::,fc00::/7,fe80::/10, or an IPv4-mapped form of any of those, it is rejected — even ifallowed_hostsis empty. The operator escape hatch is to list the exact literal (e.g.allowed_hosts = ["127.0.0.1"]for local testing); a listed literal is allowed regardless of being loopback/private. - Empty
allowed_hosts⇒ allow any public host (the IP-literal block still applies). Security is opt-in to stay backward-compatible with the many existing configs that point at public endpoints by hostname. - The SSE server-provided
endpointURL is re-checked. A hostile SSE server could hand back a POST URL pointing at internal infrastructure;SseTransport::connectruns the guard against the joinedpost_urltoo. - Rejections reuse the existing
TransportError::Other(String)(theTransportErrorenum is locked#[non_exhaustive]; no new variant). Every rejection message contains the stable literal substringblocked hostand the markerADR 0012, so the CLI hint layer (Feature 3) can detect SSRF rejections by substring without coupling to a typed variant.
{Http,Sse,Ws}Transport::connect gain a &HostGuard parameter (breaking, but
absorbed pre-publish per the release plan). run::build_session constructs the
guard via HostGuard::from_config(&config.server) and threads it in.
This reverses ADR 0007's deferral of the host-allowlist and supersedes the host-allowlist Open item of ADR 0007. Per the append-only ADR policy, 0007 is not edited; this ADR records the reversal.
- Resolve the hostname and block private resolved IPs (full anti-SSRF).
The correct long-term fix, but
reqwestandtokio-tungsteniteown the DNS resolver and the connect socket; there is no stable hook inconnect_async/ the default reqwest connector to inspect the resolved address before connecting. Doing this properly requires a custom connector + resolver pinning (resolve once, pin the IP, reject if private, dial the pinned IP). That is a meaningful surface and is deferred to v0.2 rather than blocking the v0.1 publish. - Wildcard / suffix allowlist (
*.example.com). Rejected: suffix matching is the classic source of allowlist-bypass bugs (evil-example.com,example.com.attacker.net). Exact match is unambiguous and sufficient for the load-test use case where the target set is small and known. - Block-by-default (deny all unless allow-listed). Rejected for v0.1: it
would break every existing public-hostname config on upgrade with no
security win for the common case (public endpoint over TLS). Opt-in
allowlist + always-on IP-literal block is the pragmatic balance; operators
who want strict mode set
allowed_hosts. - Add a new
TransportErrorvariant. Rejected: the enum is locked#[non_exhaustive]for M4; a stable substring inOthergives the hint layer everything it needs without an API change.
- Makes easy: safe defaults against the cloud-metadata / localhost-admin
SSRF classes for IP-literal URLs, with zero config; a one-line opt-in
(
allowed_hosts) for strict host pinning; a stable, greppable rejection message for tooling. - Makes hard / commits us to: the
connectsignature now carries a&HostGuard; future transports must thread it. The exact-match policy means operators with many subdomains must list each one (acceptable for the load-test use case). - Open question / accepted v0.1 gap — DNS rebinding. A hostname that
resolves to a private IP (
localtest.me→127.0.0.1, or attacker- controlled DNS that flips post-validation) is not blocked in v0.1, because we never see the resolved address.allowed_hostsis the mitigation (a hostname not on the list is rejected before any lookup), and the existingPolicy::none()closes the redirect vector. This residual risk is documented here, inSECURITY.md, and in the CHANGELOG. v0.2 follow-up: a custom reqwest connector + resolver-pinning (resolve, reject private, connect to the pinned IP) to close the rebinding gap end-to-end. A regression test (hostname_not_ip_blocked_in_v0_1) pins the current behavior so the v0.2 change is a deliberate, reviewed reversal.