---
id: "GHSA-v38p-wf49-hx2j"
page: "https://infrastack.ai/live/GHSA-v38p-wf49-hx2j"
kind: "vulnerability"
aliases: ["GHSA-v38p-wf49-hx2j"]
title: "fetch: private-address guard bypass via IDNA hostnames (check and connect resolve different names)"
severity: "medium"
cvss: 6.3
epss: null
exploited: false
status: "open"
category: "ai-tooling"
products: [{"name": "mcp-server-fetch", "fixed": ["2026.10.24"], "affected": ">= 2026.10.10, <= 2026.10.19", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["ai-agents", "ai-tooling", "cwe-918", "mcp", "python", "self-hosted"]
not_affected_if: ["fetch-tool-unused", "fixed-version", "no-internal-services"]
published_at: "2026-10-25T00:19:06+00:00"
first_seen_at: "2026-11-09T22:53:25+00:00"
updated_at: "2026-11-09T22:55:05+00:00"
sources: [{"source": "ghsa-repo", "url": "https://github.com/modelcontextprotocol/servers/security/advisories/GHSA-v38p-wf49-hx2j", "first_seen_at": "2026-11-09T22:53:25+00:00"}]
exploit_sources: []
---

# fetch: private-address guard bypass via IDNA hostnames (check and connect resolve different names)

Link for people: https://infrastack.ai/live/GHSA-v38p-wf49-hx2j (this .md is for agents)

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.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:mcp-server-fetch affected >= 2026.10.10, <= 2026.10.19 fixed 2026.10.24

## How to check

- Run `pip show mcp-server-fetch` and confirm the version is `2026.10.10` through `2026.10.19`.
- Check whether the fetch tool is exposed to a model that may fetch attacker-supplied URLs.

## Fix

- `mcp-server-fetch` `2026.10.24`

## Timeline

- 2026-11-09T22:53:25+00:00 ghsa-repo https://github.com/modelcontextprotocol/servers/security/advisories/GHSA-v38p-wf49-hx2j

## Documents

- [advisory] Maintainer's advisory: github.com https://github.com/modelcontextprotocol/servers/security/advisories/GHSA-v38p-wf49-hx2j

## From the sources

### Summary

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).

### Details

`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,
`c003d0d807409b22fa640d1f8e11f38f15755721`, 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 (`v2/main` at `6d613ecff7d320e0e0414a997f46bc4b3367c50d`, `server.py` L140-L154).

For an internationalized hostname the hook and the connection resolve different names:

- httpx stores the host IDNA 2008 encoded (`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 `ﬁ`) 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.

### Proof of concept

Reproduction:

1. In a DNS zone you control, create `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.
2. Run an HTTP service on the internal address (I used a stub on 127.0.0.1:80 that returns a canary
   JSON for `/latest/meta-data/...`).
3. Start `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.
4. Call the `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:

```python
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.


### Impact

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.

---

_Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai_
