Skip to content

fix(fetch): block SSRF to internal/metadata IPs by default#4497

Open
olaservo wants to merge 2 commits into
modelcontextprotocol:mainfrom
olaservo:fix/fetch-ssrf-guard
Open

fix(fetch): block SSRF to internal/metadata IPs by default#4497
olaservo wants to merge 2 commits into
modelcontextprotocol:mainfrom
olaservo:fix/fetch-ssrf-guard

Conversation

@olaservo

@olaservo olaservo commented Jul 8, 2026

Copy link
Copy Markdown
Member

Description

Adds SSRF protection to the fetch server. The fetch tool previously issued a server-side request to any caller-supplied URL with no validation of the destination host, and followed redirects without re-checking the target. Because the URL argument is model-produced and can be steered by untrusted content (indirect prompt injection from a fetched page), this could be used to reach loopback, private (RFC1918), link-local, and cloud-metadata endpoints (e.g. 169.254.169.254) and return their contents — including IMDS credentials — into the model context.

This change resolves the destination host and rejects non-public IP addresses, restricts schemes to http/https, and re-validates the destination on every redirect hop.

Server Details

  • Server: fetch (Python, mcp-server-fetch)
  • Changes to: tools (request handling / URL validation)

Motivation and Context

The fetch server accepts an arbitrary URL and performs a server-side GET. There was no allowlist/denylist for scheme, host, or IP range; no blocking of loopback / private / link-local / cloud-metadata addresses; and redirects were followed with follow_redirects=True without re-validating the target, so a public-looking URL that 302-redirects to an internal address bypassed any initial-host defense. The robots.txt pre-check used the same unguarded client.

What this PR does:

  • Adds _validate_url_is_safe(): resolves the host (handling IP literals and DNS names, including IPv4-mapped IPv6) and rejects loopback, private, link-local (incl. metadata), multicast, reserved, and unspecified addresses. Restricts schemes to http/https.
  • Follows redirects manually (_get_following_redirects) so each hop is re-validated. Applied to both the fetch path and the robots.txt pre-check.
  • Blocking is on by default. A new --allow-internal-ips flag (threaded through serve() and the CLI) restores the previous behavior for operators who deliberately need to fetch internal hosts.

How Has This Been Tested?

  • New unit tests cover blocked ranges (loopback, 169.254.169.254, RFC1918, 0.0.0.0, IPv6 loopback, IPv4-mapped IPv6), non-http schemes, a public IP literal (allowed), end-to-end rejection through fetch_url, and the --allow-internal-ips bypass.
  • Existing tests updated to stay hermetic (host validation stubbed where the HTTP client is already mocked).
  • uv run pytest (30 passed), uv run ruff check ., and uv run pyright (0 errors) all pass locally.
  • Behavior exercised via the tool path with internal/metadata targets (blocked) and public URLs (allowed).

Breaking Changes

Yes — by default the server now refuses to fetch loopback/private/link-local/metadata addresses. Deployments that intentionally fetch internal hosts (e.g. localhost) must add --allow-internal-ips. This is called out in the README (CAUTION note + new "Customization - Internal IPs" section).

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Protocol Documentation
  • My changes follows MCP security best practices
  • I have updated the server's README accordingly
  • I have tested this with an LLM client
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have documented all environment variables and configuration options

Additional context

Design note: the fetch server's README previously documented internal-IP access as an accepted risk. This PR flips the default to secure-by-default (block, with explicit opt-in) rather than opt-in hardening, since the most severe case (reading cloud instance-metadata / IAM credentials) warrants a safe default. DNS re-resolution between validation and connection leaves a narrow DNS-rebinding window; pinning the connection to the validated IP could be layered on later.

🤖 Generated with Claude Code

olaservo and others added 2 commits July 8, 2026 07:43
The fetch tool issued server-side requests to any URL with no validation
of the destination host, and followed redirects without re-checking the
target. A prompt-injection-steered URL (or a public URL that 302-redirects
to an internal address) could reach loopback, private (RFC1918),
link-local, and cloud-metadata endpoints (e.g. 169.254.169.254) and return
their contents into the model context.

Resolve the host and reject non-public IP addresses (loopback, private,
link-local/metadata, multicast, reserved, unspecified), restrict schemes to
http/https, and follow redirects manually so every hop is re-validated. The
same guard is applied to the robots.txt pre-check.

Blocking is on by default; operators who need to fetch internal hosts can
opt out with --allow-internal-ips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gets

Explicitly block 100.64.0.0/10 (not flagged by is_private before
Python 3.13) and unwrap deprecated IPv4-compatible IPv6 addresses
(::a.b.c.d, ::/96) so forms like [::127.0.0.1] are classified by
their embedded IPv4 address. Brings the fetch guard to parity with
the everything server's SSRF classifier. Adds tests for both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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