Skip to content

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

Closed
ghost wants to merge 1 commit into
mainfrom
unknown repository
Closed

fetch: harden against SSRF (private/loopback/metadata block, scheme block, redirect revalidation)#4205
ghost wants to merge 1 commit into
mainfrom
unknown repository

Conversation

@ghost

@ghost ghost commented May 19, 2026

Copy link
Copy Markdown

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.
@SyedAnas01

Copy link
Copy Markdown

mcp-safeguard detection: SS-001/SS-002 now flags this class of vulnerability

I've added SSRF detection rules to mcp-safeguard (open-source MCP security scanner) that automatically detect the vulnerability class described in this issue.

New rules (commit a1c643c):

Rule Description CVSS
SS-001 URL parameter with no allowlist/blocklist protection 7.5 HIGH
SS-002 Blind URL fetch — no scope restriction indicated 7.5–8.5 HIGH
SS-003 Redirect following without revalidation risk 6.5 MEDIUM

Scan output against mcp-server-fetch v0.6.3:

[HIGH]  SS-001  URL Parameter Without SSRF Protection
        Location: tool:mcp-server-fetch.fetch.inputSchema.url
        Evidence: Parameter(s) `url` accept unconstrained strings. No URL validation keywords found.
        CVSS: 7.5

[HIGH]  SS-002  Blind URL Fetch — No Scope Restriction Indicated
        Location: tool:mcp-server-fetch.fetch.description
        Evidence: "URL from the internet" — grants unconditional internet access
        CVSS: 7.5

The same rules flag playwright-mcp's browser_navigate tool (SS-001) and any other tool accepting unconstrained URL parameters.

One additional attack vector not yet mentioned in this issue:

The get_prompt handler in server.py calls fetch_url() directly without first calling check_may_autonomously_fetch_url(). This means the robots.txt-based protection applies only to call_tool, not to the prompt endpoint. If an agent uses the "fetch" prompt (rather than the tool), the robots.txt check is entirely bypassed — regardless of the ignore_robots_txt setting.

# call_tool — protected (calls check_may_autonomously_fetch_url first)
# get_prompt — UNPROTECTED (calls fetch_url directly, no robots.txt check)
@server.get_prompt()
async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult:
    url = arguments["url"]
    content, prefix = await fetch_url(url, user_agent_manual, ...)  # No URL validation!

The proposed fix in this PR (assert_url_safe_or_raise on "every outbound request") would cover this path — just wanted to confirm the get_prompt handler is included in the scope of the fix.

— Syed Anas Mohiuddin | mcp-safeguard

@LuuOW LuuOW left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technical audit: Verified MCP server implementation for consistency with current SDK patterns.

@ghost ghost closed this by deleting the head repository Jun 26, 2026
This pull request was closed.
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.

2 participants