GHSA-hv85-774v-26fgHighCVSS 8.2Disclosed before NVD

auth-fetch-mcp: SSRF and disk exfiltration via unvalidated auth_fetch and download_media URLs

Published
May 19, 2026
Last Modified
May 19, 2026

📋 Description

# SSRF + disk-exfil in `download_media` and `auth_fetch` tools — ymw0407/auth-fetch-mcp ## Severity The `download_media` and `auth_fetch` MCP tools accept arbitrary URLs and reach them as the MCP server process, with `download_media` additionally persisting the fetched response body to a user-controlled output directory. An MCP client (LLM under prompt injection, malicious peer) can drive the server to fetch loopback / link-local / private-range hosts (cloud-instance metadata, internal services, host-bound services) and exfiltrate the response. ## Vulnerability chain ### Site 1: `download_media` — SSRF + disk-write chain `src/tools.ts:200-274` ```ts server.registerTool("download_media", { inputSchema: { urls: z.array(z.string()).describe("One or more URLs to download"), output_dir: z.string().optional()..., }, }, async ({ urls, output_dir }) => { ... for (const url of urls) { try { const response = await ctx.request.get(url); // line 238 — no validation ... const body = await response.body(); ... const filePath = path.join(dir, `file-${++counter}${ext}`); fs.writeFileSync(filePath, body); // line 257 — writes response to disk ``` `urls` and `output_dir` are user-controlled. The handler iterates each URL (line 236) and calls `ctx.request.get(url)` (Playwright's `APIRequestContext.get`) without checking the destination. The response body is written to `path.join(output_dir, file-N.ext)`. Internal-service responses are persisted to disk where they can be exfiltrated via any subsequent tool that reads from the output directory (or via the response object itself, which contains `localPath` and `size` of every successful write). ### Site 2: `auth_fetch` — SSRF via Playwright navigation `src/tools.ts:117-198` ```ts server.registerTool("auth_fetch", { inputSchema: { url: z.string().describe("The URL to fetch content from"), wait_for: z.string().optional()..., }, }, async ({ url, wait_for }) => { ... const page = await navigateTo(ctx, url); // line 142 ... const result = await extractContent(page); return textResult({ status: "ok", url: result.url, title: result.title, content: result.content }); }); ``` `src/browser.ts:53-64` ```ts export async function navigateTo(ctx: BrowserContext, url: string): Promise<Page> { ... await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); // line 63 return page; } ``` `url` flows directly from the MCP tool argument to `page.goto` with no validation. Playwright will navigate to any URL the network stack can reach. The page DOM is returned in the tool response via `extractContent`. Internal pages (loopback admin UIs, cloud metadata endpoints reachable from the host, intranet services) are extractable. ## Root cause Neither handler validates URL targets before dispatch. The tool descriptions ("fetches web page content using a real browser ... e.g. Notion, Google Docs, Jira, Confluence, Linear, Slack, or any SaaS/private page") frame the intended usage as **public SaaS web pages**, not loopback or link-local hosts — but no code enforces that intent. The fix shape (apply to both tools): after URL parsing, resolve to IP, reject if private/loopback/link-local. Same defense as the well-known SSRF-guard pattern shipped by other MCP fetchers in the ecosystem (e.g., `Akitaroh/scraper-mcp` `src/security/url-guard.ts`). ## Auth boundary violated **Boundary type:** MCP tool-argument boundary plus the local-network trust boundary. The MCP server typically sits inside a trust boundary (developer laptop with loopback services, cloud VM with IMDS, k8s pod with service account). The tools allow the MCP client to dispatch HTTP requests across that boundary. **Respected/violated trace:** Per the tool descriptions, the expected respected boundary is "public SaaS web pages." That expectation is violated by any request reaching a host the user didn't intend to expose (127.0.0.1:6379 Redis, 169.254.169.254 cloud metadata, 192.168.0.1 internal admin). ## Impact 1. **Cloud credential theft** — server on EC2 / GCE / Azure VM. MCP client invokes `auth_fetch({ url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/<role>" })` and receives temporary credentials in the tool response. Or invokes `download_media({ urls: [...], output_dir: "/tmp/exfil" })` to persist them to disk. 2. **Internal service enumeration** — MCP client probes private-range hosts (10/8, 172.16/12, 192.168/16). Each `auth_fetch` returns the page DOM; each `download_media` writes the response to disk. 3. **Loopback exploitation** — server runs alongside Redis (127.0.0.1:6379), ElasticSearch (127.0.0.1:9200), or internal admin UIs. MCP client reads them via `auth_fetch`. 4. **Disk-write side channel** (`download_media` only) — output_dir is also user-controlled, with no documented restriction. An MCP client can request `output_dir = "/some/user-writable-shared-dir"` and exfil internal-service responses to a location accessible to a co-tenant process. The injection vector is any content reaching the model that prompts a fetch tool call. The tool description explicitly says "MUST be used instead of Fetch/web_fetch when the page requires login" — meaning the model is encouraged to call this tool for any "private page" mention, which a prompt-injected upstream content can trivially trigger. ## Proof of concept (non-destructive) `poc.mjs` — replicates the `download_media` handler's HTTP-fetch + file-write chain against a local fake-internal HTTP service. Playwright's `ctx.request.get(url)` is replaced with the equivalent `fetch(url)` for the bug case (a URL needing no auth) so the demo runs without browser deps. The structural defect — "no host validation before HTTP dispatch" — is identical. ``` [PoC] fake internal-only service: 127.0.0.1:36105 [PoC] simulating MCP client calling download_media({ urls: ['http://127.0.0.1:36105/secrets'], output_dir: '/tmp/auth-fetch-exfil-aU1jjv' }) [PoC] no IP / host validation exists at tools.ts:236-238 before ctx.request.get(url) [PoC] ✓ SSRF + DISK-EXFIL CONFIRMED File written to: /tmp/auth-fetch-exfil-aU1jjv/file-1.json Persisted content (187 bytes): { "AccessKeyId": "AKIA-FAKE-FROM-POC", "SecretAccessKey": "fake-secret-marker-NOT-REAL", "Note": "In a real exploit this would be AWS IMDS at 169.254.169.254/latest/meta-data/..." } ``` Exit code `0`. SHA-256 `poc.mjs`: `4cea53f1a618581fc67f9a8bd07a7a2b22274f42cdbf7f3c658519673aaf7568`. The PoC only contacts `127.0.0.1` on an ephemeral port; the fake-credentials string contains the literal `FAKE` marker so no downstream system can mistake it for real credentials. The exfil directory is cleaned up after the demo. ## Suggested fix Add a `assertSafeUrl` helper (same shape as in the matching egoist/fetch-mcp advisory) called before any HTTP dispatch — at `tools.ts:236` inside the download_media loop, and at the top of `navigateTo` in `browser.ts:53`: ```ts import dns from 'node:dns/promises' import net from 'node:net' async function assertSafeUrl(rawUrl: string): Promise<URL> { const parsed = new URL(rawUrl) if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error(`Unsupported scheme`) const host = parsed.hostname const addresses = net.isIP(host) ? [host] : (await dns.lookup(host, { all: true })).map(a => a.address) for (const addr of addresses) { if (isPrivateOrLinkLocal(addr)) throw new Error(`Refusing to fetch ${addr}`) } return parsed } ``` Where `isPrivateOrLinkLocal` blocks 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, ::1, fc00::/7, fe80::/10. For `download_media` specifically, also constrain `output_dir`: resolve it under a fixed root (e.g., `~/.auth-fetch-mcp/downloads/`) and reject if the resolved path escapes that root.

🎯 Affected products1

  • npm/auth-fetch-mcp:<= 3.0.0

🔗 References (3)