Add a host allowlist for the fetch server, addressing the SSRF surface
tracked in modelcontextprotocol#2317 without changing default behavior:
- New --allowed-hosts flag (exact hosts, case-insensitive; *.example.com
wildcards covering the bare domain and any subdomain; IP literals).
- Enforced on the initial request, the robots.txt pre-check, and every
redirect hop: redirects are now followed manually (bounded at 20, same
as httpx's default) and each hop is re-validated, so a redirect from
an allowed host can no longer bounce a fetch to a disallowed host.
- URL validation parses with httpx.URL — the same parser used to
connect — so the validated host is always the host being connected.
- Denials fail closed with an error that does not echo the allowlist.
Default-IP-blocking is intentionally left to the separate default-deny
proposal; with no flag set, behavior is identical to before.
Description
Adds an opt-in host allowlist to the
fetchserver, addressing the SSRF surface tracked in #2317 without changing default behavior.Fixes #2317.
--allowed-hosts HOST [HOST ...]CLI flag. When unset, behavior is exactly as before (all hosts allowed), so deployments that intentionally fetch localhost/internal endpoints are unaffected (per the discussion on the issue).example.com); wildcard (*.example.com) matching the bare domain and any subdomain; literal IPs.robots.txtpre-check, and every redirect hop: redirects are now followed manually (bounded at 20, same as httpx's default) and each hop is re-validated before connecting, so a 302 from an allowed host can no longer bounce a fetch to a disallowed host.Publishing Your Server
N/A — change to an existing server.
Server Details
mcp-server-fetch)Motivation and Context
#2317 asks for host allowlisting. The issue discussion raised valid concerns about breaking users who intentionally fetch internal endpoints, so this PR deliberately does not change the default posture — the default-deny/IP-range part of the issue is separately covered by #4497, and this PR composes cleanly with it. A previous allowlisting attempt (#2568) went stale and was closed with an invitation to resubmit on a current base; this is that fresh take, scoped to avoid the default-behavior controversy.
On the TOCTOU/socket-level point in the issue: with a hostname allowlist, the validated hostname is exactly what gets connected (SNI/Host), so DNS check-time/connect-time skew does not weaken the control; the realistic bypass was redirect hops, which this closes.
How Has This Been Tested?
uv run pytest— 64 passed (existing suite untouched and green, plus new coverage: exact/wildcard/IP matching, case/trailing-dot normalization, userinfo bypass attempt, per-hop redirect enforcement parametrized over 301/302/303/307/308, redirect-loop limit, empty and malformedLocationheaders (matching httpx's prior behavior), relativeLocationresolution, robots.txt path enforcement, per-request timeout semantics, and unset-flag backwards compatibility).uv run pyright— 0 errors;uv run ruff check .— clean.--allowed-hosts 127.0.0.1, plus a real local HTTP server — allowlisted host fetched,http://169.254.169.254/latest/meta-datablocked, redirect to a non-allowlisted host blocked, malformedLocationhandled with a clean error.httpx.URL— the same parser that performs the connection — so the validated host is always the host that gets connected.Breaking Changes
None — default behavior is unchanged when
--allowed-hostsis not set.Types of changes
Checklist
Additional context
*.example.commatchesexample.comitself and any subdomain.