MCP-SERVER-FETCH · GHSA-v38p-wf49-hx2j · AI agents & MCP
FIX → 2026.10.24
The mcp-server-fetch private-address check resolves a different hostname than the one httpx connects to for internationalized domain names. An attacker who controls a domain can make the server reach loopback, private-network or cloud-metadata endpoints and return the response to the model.
first public Oct 25, 2026 · 1 source
Could this affect your app?
Free first check from your website. Connect your repo for the full answer.
Any system running mcp-server-fetch 2026.10.10 through 2026.10.19 with default settings, typically as a fetch tool for an AI agent, is exposed. The guard only matters where the server runs on a machine with internal services or cloud metadata endpoints. Systems on another version, or where the fetch tool is not reachable by a model that might read attacker-controlled URLs, are less exposed.
Not affected if fetch-tool-unused, fixed-version, no-internal-services
Who cares ai-agents, mcp, python, self-hosted
pip show mcp-server-fetch and confirm the version is 2026.10.10 through 2026.10.19.Upgrade to 2026.10.24
mcp-server-fetch The private-address guard added in mcp-server-fetch 2026.10.10 checks a different hostname than
the one httpx connects to when the URL has an internationalized (IDNA) host: the guard resolves the
IDNA 2003 form (faß becomes fass) while httpx connects to the IDNA 2008 form (xn--fa-hia).
Anyone who controls a domain can point the two names at a public and an internal address and reach
loopback, private-network or cloud-metadata endpoints through the fetch tool in its default
configuration, which the README says refuses those addresses. Severity: CVSS 4.0 6.3 (Medium); the
CVSS 3.1 equivalent is 7.4 (High).
mcp-server-fetch 2026.10.10 added a guard that refuses loopback, private, link-local and cloud
metadata addresses by default (PR #5033, the fix for the SSRF tracked as CVE-2026-104120). The guard
is an httpx request hook, _refuse_private_destination, which resolves request.url.host with
getaddrinfo and refuses the request if any address is not public
(src/fetch/src/mcp_server_fetch/server.py L119-L143 at tag v1.0.0,
, which is the published 2026.10.10 code). The fetch README
states that by default the server refuses non-public addresses, including cloud metadata endpoints,
and lists two limits: DNS rebinding between the check and the connection, and hostnames the server
cannot resolve itself (proxy-only names). The issue below is neither. It is still present on the
development line ( at , L140-L154).
Check it with your coding agent
It reads this page and tells you if your code is affected.
Are you affected?
Ask from here. InfraStack checks the affected versions against your stack and answers in your inbox.
InfraStack checks the affected versions against your stack and answers in Slack, with the evidence and the fix.
2026.10.24c003d0d807409b22fa640d1f8e11f38f15755721v2/main6d613ecff7d320e0e0414a997f46bc4b3367c50dserver.pyFor an internationalized hostname the hook and the connection resolve different names:
URL.raw_host, e.g. xn--fa-hia.attacker.example) and
connects to that name.URL.host, which the hook passes to getaddrinfo, is the decoded Unicode form
(faß.attacker.example). CPython's socket.getaddrinfo encodes a Unicode name with the built-in
idna codec, which is IDNA 2003 and maps ß to ss, so the hook looks up
fass.attacker.example.The domain owner points fass. at any public address and xn--fa-hia. at an internal one. The hook
approves the public answer and httpx connects to the internal address. There is no timing involved,
so this is separate from the documented DNS-rebinding limit.
The same mismatch also reaches the documented proxy-only branch (L130-L133, which skips the check when the hook's lookup fails) for a name the server does resolve: if the IDNA 2003 name does not exist, the hook skips the check, and httpx resolves the IDNA 2008 name itself and connects directly, with no proxy configured. The fix below covers both.
Both apply to the URL the model passes to the fetch tool and to every redirect hop, since all of
them go through the same hook. ß is one example; final sigma ς gives the same split (σοφος is
xn--0xaajbq for the connector and xn--0xaakcn for the hook). Characters that httpx's IDNA 2008
encoder rejects (ZWJ/ZWNJ, compatibility forms such as Ⅳ or fi) cannot be used.
Suggested fix: resolve and check the exact name the connection uses,
request.url.raw_host.decode("ascii") instead of request.url.host, and treat a resolution failure
as a refusal unless a proxy is configured. Doing the check inside the transport (resolve once,
connect to the checked address) would also close the documented DNS-rebinding gap.
Reproduction:
fass.<zone> with any public A record and
xn--fa-hia.<zone> with an internal A record (I used 127.0.0.1, standing in for an internal or
metadata endpoint). Optionally leave fass.nx.<zone> undefined and create
xn--fa-hia.nx.<zone> -> 127.0.0.1 to see the proxy-only branch reached without a proxy./latest/meta-data/...).mcp-server-fetch 2026.10.10 as python -m mcp_server_fetch with no arguments (no
--allow-private-ips, no --proxy-url) and no relevant environment variables, so the guard is on.
Client: the MCP Python SDK 1.30.0 (ClientSession.call_tool) over stdio.fetch tool with http://xn--fa-hia.<zone>/latest/meta-data/... (or the Unicode form
http://faß.<zone>/...). The internal service's response comes back as the tool result.
The same request with a plain name resolving to 127.0.0.1, or with http://127.0.0.1/..., is
refused by the guard.My run, in one disposable python:3.12-slim container with a toy DNS server on 127.0.0.1:53 and the
stub on 127.0.0.1:80 (nothing outside the container contacted after installing the package).
Versions: Python 3.12.15, mcp-server-fetch 2026.10.10, httpx 0.28.1, idna 3.20, mcp 1.30.0. The real
server was driven over stdio with the MCP Python client, calling fetch with raw: true:
| URL | Result |
|---|---|
http://127.0.0.1/latest/meta-data/... | refused: "127.0.0.1 resolves to 127.0.0.1, which is not a public address" |
http://direct.attacker.test/latest/meta-data/... | refused (resolves to 127.0.0.1) |
http://xn--fa-hia.attacker.test/latest/meta-data/... | returned the internal service's response ({"AccessKeyId": "ASIA-POC-CANARY", ...}) |
http://faß.attacker.test/latest/meta-data/... | returned the internal response |
http://xn--fa-hia.nx.attacker.test/latest/meta-data/... | returned the internal response (hook lookup NXDOMAIN; the server itself resolved and connected to the IDNA 2008 name, no proxy) |
The internal service logged the requests with Host: xn--fa-hia.attacker.test and
Host: xn--fa-hia.nx.attacker.test, including the robots.txt check, which takes the same path.
Root cause in three lines:
import httpx
u = httpx.URL("http://xn--fa-hia.attacker.test/")
print(u.host, u.raw_host, u.host.encode("idna"))
# faß.attacker.test b'xn--fa-hia.attacker.test' b'fass.attacker.test'
I can share the full container script (DNS server, stub service, MCP client) if it helps.
Server-side request forgery (CWE-918) through a bypass of the default egress guard. Affected: anyone
running mcp-server-fetch 2026.10.10 with default settings, typically as a tool for an AI agent. The
guard exists to stop a model-supplied URL, for example one planted by prompt injection in fetched
content, from reaching loopback services, RFC 1918 hosts and cloud metadata endpoints from the
machine running the server. With this bypass an attacker who controls any domain gets that access
back, and the response is returned to the model, so it can be read or passed on.
Limits: the attacker needs the model to fetch an attacker URL (prompt injection or a user request); what is reachable depends on where the server runs; I tested a loopback service standing in for a metadata endpoint.