Skip to content

fetch: harden against SSRF (private/loopback/metadata block, scheme block, redirect revalidation)#4205

Open
JAE0Y2N wants to merge 1 commit into
modelcontextprotocol:mainfrom
JAE0Y2N:harden-fetch-against-ssrf
Open

fetch: harden against SSRF (private/loopback/metadata block, scheme block, redirect revalidation)#4205
JAE0Y2N wants to merge 1 commit into
modelcontextprotocol:mainfrom
JAE0Y2N:harden-fetch-against-ssrf

Conversation

@JAE0Y2N
Copy link
Copy Markdown

@JAE0Y2N JAE0Y2N commented May 19, 2026

The fetch tool currently passes any user-controlled URL straight to httpx.get with follow_redirects=True. Combined with the model-callable nature of MCP tools, this turns the server into a confused-deputy SSRF primitive whenever someone can influence a prompt: a malicious string fed to the model can be turned into a request against http://127.0.0.1, http://169.254.169.254/latest/meta-data/iam/..., RFC1918 ranges, or file:///etc/passwd (httpx honors file://). On EC2 deployments the IAM-credential exfiltration path is the most concerning; on developer laptops the local-services exposure is the most common; on office networks the internal-services exposure matters.

The SECURITY.md note that the reference servers aren't bounty-eligible is noted, so I'm landing this as a hardening contribution rather than a vulnerability report. The patch keeps behavior identical for normal external-URL fetches and adds a backstop for the three concrete bypass surfaces.

What landed

assert_url_safe_or_raise(url, allow_private_networks) is now called before every outbound request:

  1. Scheme is locked to {http, https}. Everything else (file://, gopher://, dict://, ftp://, …) raises INVALID_PARAMS. This is unconditional — the opt-in flag below does not unlock it.
  2. The hostname is resolved via socket.getaddrinfo and every returned IP is checked. If any is loopback / link-local / RFC1918 / multicast / reserved / unspecified, the URL is rejected. The "any" semantics matter because httpx's pool may pick any A/AAAA entry — a single loopback record in the response is enough to win a race.
  3. Empty hostnames (http:///path) and DNS failures are treated as rejection, not fail-open.

fetch_url now follows redirects manually with follow_redirects=False and re-runs assert_url_safe_or_raise against each Location header. Without this, a public 302 to http://127.0.0.1/ would silently bypass the gate. Redirect chains are bounded by MAX_REDIRECTS = 10.

--allow-private-networks is a new opt-in CLI flag for the legitimate use cases (developer-loop tooling, internal-network scraping behind a trusted egress proxy). When set, the IP-range check is skipped, but the scheme block stays.

Tests

22 new tests in TestAssertUrlSafe and TestFetchUrlRedirectRevalidation covering:

  • file://, gopher://, dict:// scheme rejection
  • IPv4 loopback literal (127.0.0.1 + 127.255.255.254)
  • IPv6 loopback literal ([::1])
  • AWS metadata IP (169.254.169.254 link-local)
  • RFC1918 (10.0.0.5, 192.168.1.1, 172.20.0.1)
  • Unspecified (0.0.0.0)
  • Empty hostname
  • Hostname resolving to loopback
  • Hostname with multi-IP set where any IP is blocked
  • DNS resolution failure
  • Public-hostname pass (positive)
  • Opt-in flag permits private but still blocks file://
  • 302 from public URL to 127.0.0.1 is rejected
  • Infinite redirect loop bounded by MAX_REDIRECTS

All 41 tests pass (19 existing + 22 new). ruff check clean, pyright clean.

Known residual

The hostname → IP check is best-effort against DNS rebinding — the IP seen at validation may differ from the IP that httpx connects to. For full assurance, run the fetch server behind a network egress filter that enforces the same policy at the connection layer. Documented in the docstring.

Manual reproducer (for reviewers who want to verify)

Before the patch (with bogus env API keys), starting the fetch server and asking the model:

Use the fetch tool to GET http://169.254.169.254/latest/meta-data/iam/security-credentials/

returns the EC2 metadata service response. After the patch the same request raises INVALID_PARAMS with the message "target IP 169.254.169.254 is a link-local address (covers cloud-metadata endpoints)".

— Jaeyoung Yun

…heme block, redirect revalidation)

The fetch tool previously passed any user-controlled URL directly to
httpx.get with follow_redirects=True. Combined with the model-callable
nature of MCP tools, this enabled an attacker who could influence a
prompt to make the host fetch arbitrary local-network or
cloud-metadata resources. On EC2 hosts this is enough to read the
IAM-role token via http://169.254.169.254/latest/meta-data/iam/...;
on developer laptops it exposes localhost services; on office
networks it exposes RFC1918 internal services. file:// scheme is also
honored by httpx and lets the tool double as a local-file read
primitive.

Defenses landed:

* assert_url_safe_or_raise() rejects non-http(s) schemes (file://,
  gopher://, dict://, ftp://, …) and resolves the hostname to its
  full IP set, rejecting any whose IP falls in loopback /
  link-local / RFC1918 / multicast / reserved / unspecified ranges.
  "any IP in the set is blocked" matters because httpx's connection
  pool may pick any A/AAAA record returned by DNS — a single
  loopback entry is enough to win the race.

* fetch_url() now follows redirects manually with follow_redirects=
  False and per-hop revalidation, so a public URL that 302s to
  http://127.0.0.1/ is rejected at the second hop rather than
  silently followed. Bounded to MAX_REDIRECTS to prevent loops.

* New --allow-private-networks CLI flag (defaults off) for the
  legitimate use cases — developer-loop tooling, internal-network
  scraping behind a trusted egress allowlist. The scheme block is
  unconditional even when this flag is set.

* 22 new tests covering each rejection class (literal IP, hostname
  resolution, multi-A-record, DNS-failure, redirect revalidation,
  opt-in escape hatch). Ruff + pyright clean.

Known residual risk: DNS rebinding between resolution and connect.
The hostname → IP check is best-effort. For higher assurance run the
fetch server behind a network egress filter that enforces the same
policy.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant