fix(fetch): block SSRF to internal/metadata IPs by default - #4497
fix(fetch): block SSRF to internal/metadata IPs by default#4497olaservo wants to merge 7 commits into
Conversation
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>
There was a problem hiding this comment.
Pull request overview
This PR hardens the mcp-server-fetch MCP server against SSRF by validating URL schemes, resolving and blocking non-public destination IPs, and re-validating destinations on each redirect hop, with an opt-out flag for operators.
Changes:
- Added SSRF guard that blocks loopback/private/link-local/metadata and other non-public IP ranges by default (http/https only).
- Replaced automatic redirect following with manual redirect handling to re-validate each hop.
- Added CLI/server flag
--allow-internal-ipsto explicitly disable SSRF protection, plus documentation and test updates.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/fetch/src/mcp_server_fetch/server.py | Implements IP-based SSRF validation and manual redirect following with per-hop validation and an allowlist bypass flag. |
| src/fetch/tests/test_server.py | Stubs SSRF validation for existing hermetic tests and adds new SSRF guard unit tests. |
| src/fetch/src/mcp_server_fetch/init.py | Adds --allow-internal-ips CLI flag and threads it into serve(). |
| src/fetch/README.md | Updates security warning and documents internal-IP opt-in configuration. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Addresses Copilot review feedback on modelcontextprotocol#4497: - _resolve_host_ips now strips IPv6 zone/scope ids (e.g. fe80::1%eth0) before parsing so scoped addresses are still classified, and raises instead of returning an empty list when no resolved address parses. Previously an all-unparseable resolution let _validate_url_is_safe iterate zero IPs and silently accept the URL (SSRF fail-open). - Add tests: zone-id stripping blocks link-local, unparseable resolution fails closed, and a public->internal redirect is rejected at the hop before the internal host is fetched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
src/fetch/src/mcp_server_fetch/server.py:156
- Similarly, failing closed when getaddrinfo() returns only unparsable addresses should be reported as INVALID_PARAMS rather than INTERNAL_ERROR, since it’s still an invalid/unsupported destination supplied by the caller.
# Fail closed: if resolution produced no address we could parse and
# classify, refuse rather than fall through to an empty (allow-all) check.
if not ips:
raise McpError(ErrorData(
code=INTERNAL_ERROR,
message=f"Failed to resolve host {host} to a usable IP address.",
))
Addresses Copilot review feedback on modelcontextprotocol#4497: - The scheme lock (http/https only) is now always enforced. Previously --allow-internal-ips skipped _validate_url_is_safe entirely, which also disabled the scheme check; the flag now only relaxes the private-IP check (check_private_ips=False) and never unlocks file:// or other non-http(s) schemes. Added a regression test asserting file:// is refused (and the HTTP client is never called) under the bypass. - _resolve_host_ips now raises INVALID_PARAMS (not INTERNAL_ERROR) on DNS failure / no parseable IPs, since these are caller-supplied input errors, matching the other URL-validation errors. - Redirect loop enforces the cap before following the next hop, so it no longer processes MAX_REDIRECTS+1 redirect responses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Python's ipaddress module covers the NAT64, IPv4-translated and reserved prefixes for free, but not 6to4 (2002::/16), which embeds the IPv4 address in bits 16-48 rather than the low 32 bits. Wherever a 6to4 relay is reachable, 2002:a9fe:a9fe:: is a route to the cloud metadata service and the guard was classifying it as public. Deprecated site-local (fec0::/10) is likewise not flagged by is_private but is still routed internally by some stacks. Unwrap 6to4 to its embedded IPv4 address and block fec0::/10, matching the everything server's guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/fetch/src/mcp_server_fetch/server.py:126
- On supported Python 3.10–3.12 runtimes,
ipaddress.is_privatehas known false negatives for special-purpose, non-global ranges. For example,192.0.0.8is outside the older192.0.0.0/29table, and none of the other predicates here block it, so this guard permits a non-public destination despite the documented policy. Please backport the corrected special-purpose ranges (including the.9/.10global exceptions) into explicit constants and add a regression test rather than relying on the runtime's version-dependent classification.
or ip.is_private
This comment was marked as spam.
This comment was marked as spam.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
src/fetch/src/mcp_server_fetch/server.py:203
- Malformed user or redirect URLs can raise
ValueErrorhere instead ofMcpError:urlparserejects malformed IPv6 authorities, andparsed.portrejects non-numeric or out-of-range ports. These exceptions are not caught byfetch_url, and the prompt path passes an unvalidated string, so one malformed URL escapes the MCP error handling. Parse the URL and extract its port inside aValueErrorhandler that reportsINVALID_PARAMS.
parsed = urlparse(url)
src/fetch/src/mcp_server_fetch/server.py:272
- The robots.txt path is also security-sensitive, but every existing robots test disables
_validate_url_is_safevia the new autouse fixture, and the added redirect test covers onlyfetch_url. Add a robots-path regression test showing that an internal robots URL (and preferably a public robots URL redirecting internally) is rejected before the client requests the blocked hop.
response = await _get_following_redirects(
The guard resolves each destination in this process, but a proxy resolves the hostname again for the forwarded request or CONNECT. An attacker-controlled hostname can answer public to the server and internal to the proxy, so the destination the proxy reaches was never validated - no timing race needed. This is not limited to --proxy-url: httpx's AsyncClient defaults to trust_env=True, so HTTP_PROXY / HTTPS_PROXY / ALL_PROXY in the environment route requests through a proxy without the operator opting in at all. Warn on stderr at startup when either is detected, and document the limitation in the README next to the SSRF claims. Warning rather than refusing keeps existing proxy deployments working; enforcement has to live at the proxy. Also document the DNS-rebinding window, which is inherent to validating a destination before the connection resolves it. Reported by Copilot review on modelcontextprotocol#4497. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/fetch/src/mcp_server_fetch/server.py:276
URL.join()can raisehttpx.InvalidURLwhen a server returns a malformedLocationheader.InvalidURLis not anHTTPError, so the callers' existingexcept HTTPErrorblocks do not catch it; a remote response can therefore escape the tool as an unhandled exception. Mirror HTTPX's automatic redirect handling by catchingInvalidURLhere and converting it to a handledMcpError(orRemoteProtocolError).
current_url = str(URL(current_url).join(location))
src/fetch/src/mcp_server_fetch/server.py:221
urlparse()and access tohostname/portcan raiseValueErrorfor malformed authorities (for example, an unmatched IPv6 bracket or a non-numeric port). That exception is not caught byfetch_urlor the prompt handler, so a malformed direct URL or redirect target escapes as an unhandled server error instead ofINVALID_PARAMS. Parse these components together and convert parser failures toMcpError.
This issue also appears on line 276 of the same file.
parsed = urlparse(url)
Description
Adds SSRF protection to the
fetchserver. Thefetchtool 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
mcp-server-fetch)Motivation and Context
The
fetchserver 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 withfollow_redirects=Truewithout 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:
_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._get_following_redirects) so each hop is re-validated. Applied to both the fetch path and the robots.txt pre-check.--allow-internal-ipsflag (threaded throughserve()and the CLI) restores the previous behavior for operators who deliberately need to fetch internal hosts.How Has This Been Tested?
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 throughfetch_url, and the--allow-internal-ipsbypass.uv run pytest(30 passed),uv run ruff check ., anduv run pyright(0 errors) all pass locally.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
Checklist
Additional context
Design note: the
fetchserver'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
Prior art
This isn't the first attempt to harden
fetchagainst SSRF — credit to earlier independent efforts on the same issue:--allow-private-networks).This PR was developed independently but lands on a similar design, defaulting to secure-by-default with an
--allow-internal-ipsopt-in.