Skip to content

zeph-mcp HTTP transport: SSRF guard has a DNS-rebinding TOCTOU gap (same defect class as #5380, fix not applied here) #6057

Description

@bug-ops

Description

zeph-mcp's HTTP transport connection paths validate the target URL for SSRF via validate_url_ssrf() (crates/zeph-mcp/src/client.rs:1148-1179), which resolves the hostname with tokio::net::lookup_host and rejects private/loopback/link-local IPs. This check is a standalone pre-flight lookup — its result (the validated SocketAddrs) is discarded, and the actual connection is established moments later using the same hostname string, via a code path that performs its own independent DNS resolution:

  • connect_url() (client.rs:488-526): validate_url_ssrf(url) at line 498, then StreamableHttpClientTransport::from_uri(url.to_owned()) at line 501 — the transport's internal HTTP client re-resolves the hostname itself.
  • connect_url_with_headers() (client.rs:545-610): validate_url_ssrf(url) at line 556, then StreamableHttpClientTransportConfig::with_uri(url) + StreamableHttpClientTransport::with_client(reqwest::Client::default(), config) at lines 582-585 — a bare default reqwest::Client with no resolve_to_addrs pinning and no hardened redirect policy.
  • connect_url_oauth() (client.rs:630-...): validate_url_ssrf(url) at line 644, then (cached-token path) AuthClient::new(reqwest::Client::default(), manager) + StreamableHttpClientTransportConfig::with_uri(url) at lines 677-680 — same pattern.

An attacker who controls DNS for the target hostname (a dynamically-added MCP server via /mcp add, or a plugin-declared [[mcp.servers]] entry installed from the marketplace — the exact case trusted: bool is false for, per issue #1441) can return a public IP for the validate_url_ssrf lookup (passing the check) and then switch the DNS record to a private/internal address (e.g. 127.0.0.1, a cloud metadata endpoint 169.254.169.254) before the transport's own, later, independent DNS resolution — bypassing the SSRF guard entirely via classic DNS rebinding. with_client(reqwest::Client::default(), ...) additionally has reqwest's default redirect policy (follows up to 10 redirects, any host), so even a same-instant race is unnecessary: a malicious MCP server can simply 3xx-redirect the initial (validated) request toward an internal address.

This is the same defect class already fixed elsewhere in this codebase, but missed in zeph-mcp

Issue #5380 (a2a client: ssrf_protection has a DNS-rebinding TOCTOU gap and is never enabled in production) described and PR #5389 fixed the identical pattern in zeph-a2a's client and zeph-tools/scrape.rs:

  • extracted a shared zeph_common::net::resolve_and_validate(host, port) -> Vec<SocketAddr> helper (crates/zeph-common/src/net.rs:139-157) that performs the one authoritative resolution,
  • pins the actual HTTP client to those exact addresses via reqwest::ClientBuilder::resolve_to_addrs(host, addrs) so the connect-time resolver cannot diverge from the validated one,
  • and hardens the redirect policy (Policy::none() plus https_only when TLS is required) so a redirect response cannot route the connection to an unvalidated host.

zeph-mcp already depends on zeph-common (crates/zeph-mcp/Cargo.toml:48), so resolve_and_validate is available for reuse without a new dependency — this was simply not included in PR #5389's fix, which touched only zeph-a2a, zeph-common, and zeph-tools/scrape.rs.

Reproduction

  1. Add a dynamic (non-config, trusted=false) MCP HTTP server pointing at an attacker-controlled hostname with a very short DNS TTL.
  2. First resolution (during validate_url_ssrf) returns a public IP — check passes.
  3. Flip the DNS record to 127.0.0.1 (or an internal/metadata address) before/while the transport performs its own connection-time resolution.
  4. The MCP handshake proceeds against the internal address; validate_url_ssrf's check is bypassed.
    (Redirect variant: point the initial public-IP response at a 3xx redirect to http://127.0.0.1/...reqwest::Client::default()'s redirect policy follows it with no re-validation.)

Expected Behavior

Mirror the zeph-a2a/zeph-tools fix: resolve once via zeph_common::net::resolve_and_validate, pin the reqwest::Client used by StreamableHttpClientTransport::with_client to those addresses via .resolve_to_addrs(host, &addrs), and set a redirect policy that either disables redirects or re-validates the target before following.

Actual Behavior

SSRF validation and the actual network connection are decoupled, independent DNS resolutions — the validated address is never enforced at connect time, and default redirect-following applies no re-validation.

Environment

  • Version/commit: 03e162f (HEAD at CI cycle 1356, 2026-07-11)
  • Features: default (MCP client is always compiled; HTTP transport used when [[mcp.servers]] url or /mcp add <url> is configured)

Spec

No existing /specs/ entry covers MCP client SSRF posture specifically; specs/014-a2a/spec.md's "Client Security Posture" section (added by PR #5389) is the closest precedent and should be mirrored for zeph-mcp alongside the fix.

Related

Metadata

Metadata

Assignees

Labels

P1High ROI, low complexity — do next sprintbugSomething isn't workingsecuritySecurity-related issue

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions