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
- Add a dynamic (non-config,
trusted=false) MCP HTTP server pointing at an attacker-controlled hostname with a very short DNS TTL.
- First resolution (during
validate_url_ssrf) returns a public IP — check passes.
- Flip the DNS record to
127.0.0.1 (or an internal/metadata address) before/while the transport performs its own connection-time resolution.
- 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
Description
zeph-mcp's HTTP transport connection paths validate the target URL for SSRF viavalidate_url_ssrf()(crates/zeph-mcp/src/client.rs:1148-1179), which resolves the hostname withtokio::net::lookup_hostand rejects private/loopback/link-local IPs. This check is a standalone pre-flight lookup — its result (the validatedSocketAddrs) 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, thenStreamableHttpClientTransport::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, thenStreamableHttpClientTransportConfig::with_uri(url)+StreamableHttpClientTransport::with_client(reqwest::Client::default(), config)at lines 582-585 — a bare defaultreqwest::Clientwith noresolve_to_addrspinning 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 casetrusted: boolisfalsefor, per issue #1441) can return a public IP for thevalidate_url_ssrflookup (passing the check) and then switch the DNS record to a private/internal address (e.g.127.0.0.1, a cloud metadata endpoint169.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 inzeph-a2a's client andzeph-tools/scrape.rs: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,reqwest::ClientBuilder::resolve_to_addrs(host, addrs)so the connect-time resolver cannot diverge from the validated one,Policy::none()plushttps_onlywhen TLS is required) so a redirect response cannot route the connection to an unvalidated host.zeph-mcpalready depends onzeph-common(crates/zeph-mcp/Cargo.toml:48), soresolve_and_validateis available for reuse without a new dependency — this was simply not included in PR #5389's fix, which touched onlyzeph-a2a,zeph-common, andzeph-tools/scrape.rs.Reproduction
trusted=false) MCP HTTP server pointing at an attacker-controlled hostname with a very short DNS TTL.validate_url_ssrf) returns a public IP — check passes.127.0.0.1(or an internal/metadata address) before/while the transport performs its own connection-time resolution.validate_url_ssrf's check is bypassed.(Redirect variant: point the initial public-IP response at a
3xxredirect tohttp://127.0.0.1/...—reqwest::Client::default()'s redirect policy follows it with no re-validation.)Expected Behavior
Mirror the
zeph-a2a/zeph-toolsfix: resolve once viazeph_common::net::resolve_and_validate, pin thereqwest::Clientused byStreamableHttpClientTransport::with_clientto 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
default(MCP client is always compiled; HTTP transport used when[[mcp.servers]] urlor/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 forzeph-mcpalongside the fix.Related
zeph-a2a, fixed by PR fix(a2a): close SSRF DNS-rebinding TOCTOU and redirect bypass, replace boolean-blind with_security #5389)trusted: boolskip-SSRF-for-config-servers design that makes the dynamic/plugin-declared path the actual attack surface here)