Description
Static review of public source at commit f46d9578190b. No traffic was sent to any MCP environment.
The Fetch MCP server retrieves arbitrary URLs with redirects enabled and no private/link-local/metadata IP checks:
src/fetch/src/mcp_server_fetch/server.py:
async with AsyncClient(proxy=proxy_url) as client:
response = await client.get(
url,
follow_redirects=True,
headers={"User-Agent": user_agent},
timeout=30,
)The README already documents the risk (This server can access local/internal IP addresses…). Robots.txt handling is present; SSRF egress control is not. A model-controlled url (or a redirect hop) can therefore reach loopback, RFC1918, link-local, or cloud metadata endpoints from the host that runs the server.
Suggested change (defense-in-depth, opt-out for demos):
- Resolve hostname → IP(s) before connect; block private/special-purpose ranges and known metadata addresses by default (revalidate on each redirect hop).
- Add
--allow-private-ips/ env escape hatch and document it next to the existing CAUTION. - Prefer the same posture pydantic-ai’s
safe_download/ similar libraries use (block-by-default, explicit allow_local).
Severity: medium as insecure default for an agent-facing fetch tool; README already discloses the class of risk. Not filed as a novel CVE claim. No proof-of-concept.
Happy to send a focused PR if useful.
Activity
yinnho commentedon Sep 23, 2026
Implementation data point — we ended up building exactly this fence for the fetch/download tools of our MCP browser (aginxbrowser, Rust with reqwest underneath), and the detail that mattered most was where the check lives.
A resolve-then-connect pre-check (per the sketch above) still leaves a small TOCTOU: the classic rebinding trick hands the pre-check a public A record and the actual dial a private one. We put the check inside a custom
Resolveimpl handed to the client viadns_resolver(...), so the filter runs on the exact address list the dialer is about to use. That closes the rebinding window, and as a side effect redirect hops need no separate bookkeeping — every dial, first or fifth, passes the same gate. We still keep a cheap URL-level pre-check per hop (scheme allow-list, literal IP ranges,localhostspellings) so the common cases fail before any DNS happens.Two ranges worth adding to the deny set that a first pass tends to miss:
100.64.0.0/10CGNAT — Alibaba's metadata endpoint (100.100.200.x family) lives there, not in 169.254-land — and0.0.0.0, which the OS happily routes to localhost. We also fail closed when any record of a multi-record answer is forbidden (mixed A-sets are where the bypass usually hides), and made the opt-out a host-side env/flag rather than a request parameter — the model calling the tool shouldn't be able to open the gate it's being fenced from. Only friction so far: local smoke tests need the env once at startup.Happy to share the resolver shape if a PR lands here.
test(fetch): drive serve() through the protocol and reach 100% coverage
fix(fetch): list special-purpose ranges explicitly, not only via is_g…
docs(fetch): name the special-purpose ranges the guard refuses (#4838)
fix(fetch): add 6to4 2002::/16 to the explicit non-public ranges (#4838)
fix(fetch): allow the global exceptions inside refused special-purpos…
Merge pull request #5033 from modelcontextprotocol/v2/fix/4838-fetch-…
cliffhall commentedon Oct 5, 2026
Fixed by #5033, merged into
v2/main(Wave 5 of #5004).fix(fetch): refuse private, loopback and metadata addresses by default (
fix(smoke): start fetch with --allow-private-ips for its loopback page (