{"id":"GHSA-hv85-774v-26fg","aliases":[],"url":"https://o3.security/vulnerability/GHSA-hv85-774v-26fg","summary":"auth-fetch-mcp: SSRF and disk exfiltration via unvalidated auth_fetch and download_media URLs","details":"# SSRF + disk-exfil in `download_media` and `auth_fetch` tools — ymw0407/auth-fetch-mcp\n\n## Severity\nThe `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.\n\n## Vulnerability chain\n\n### Site 1: `download_media` — SSRF + disk-write chain\n\n`src/tools.ts:200-274`\n```ts\nserver.registerTool(\"download_media\", {\n  inputSchema: {\n    urls: z.array(z.string()).describe(\"One or more URLs to download\"),\n    output_dir: z.string().optional()...,\n  },\n}, async ({ urls, output_dir }) => {\n  ...\n  for (const url of urls) {\n    try {\n      const response = await ctx.request.get(url);   // line 238 — no validation\n      ...\n      const body = await response.body();\n      ...\n      const filePath = path.join(dir, `file-${++counter}${ext}`);\n      fs.writeFileSync(filePath, body);              // line 257 — writes response to disk\n```\n\n`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).\n\n### Site 2: `auth_fetch` — SSRF via Playwright navigation\n\n`src/tools.ts:117-198`\n```ts\nserver.registerTool(\"auth_fetch\", {\n  inputSchema: {\n    url: z.string().describe(\"The URL to fetch content from\"),\n    wait_for: z.string().optional()...,\n  },\n}, async ({ url, wait_for }) => {\n  ...\n  const page = await navigateTo(ctx, url);           // line 142\n  ...\n  const result = await extractContent(page);\n  return textResult({ status: \"ok\", url: result.url, title: result.title, content: result.content });\n});\n```\n\n`src/browser.ts:53-64`\n```ts\nexport async function navigateTo(ctx: BrowserContext, url: string): Promise<Page> {\n  ...\n  await page.goto(url, { waitUntil: \"domcontentloaded\", timeout: 30000 });  // line 63\n  return page;\n}\n```\n\n`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.\n\n## Root cause\nNeither 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.\n\nThe 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`).\n\n## Auth boundary violated\n**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.\n\n**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).\n\n## Impact\n\n1. **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.\n\n2. **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.\n\n3. **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`.\n\n4. **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.\n\nThe 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.\n\n## Proof of concept (non-destructive)\n\n`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.\n\n```\n[PoC] fake internal-only service: 127.0.0.1:36105\n[PoC] simulating MCP client calling download_media({\n        urls: ['http://127.0.0.1:36105/secrets'],\n        output_dir: '/tmp/auth-fetch-exfil-aU1jjv'\n      })\n[PoC] no IP / host validation exists at tools.ts:236-238 before ctx.request.get(url)\n[PoC] ✓ SSRF + DISK-EXFIL CONFIRMED\n        File written to: /tmp/auth-fetch-exfil-aU1jjv/file-1.json\n        Persisted content (187 bytes):\n          {\n            \"AccessKeyId\": \"AKIA-FAKE-FROM-POC\",\n            \"SecretAccessKey\": \"fake-secret-marker-NOT-REAL\",\n            \"Note\": \"In a real exploit this would be AWS IMDS at 169.254.169.254/latest/meta-data/...\"\n          }\n```\n\nExit 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.\n\n## Suggested fix\n\nAdd 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`:\n\n```ts\nimport dns from 'node:dns/promises'\nimport net from 'node:net'\n\nasync function assertSafeUrl(rawUrl: string): Promise<URL> {\n  const parsed = new URL(rawUrl)\n  if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error(`Unsupported scheme`)\n  const host = parsed.hostname\n  const addresses = net.isIP(host)\n    ? [host]\n    : (await dns.lookup(host, { all: true })).map(a => a.address)\n  for (const addr of addresses) {\n    if (isPrivateOrLinkLocal(addr)) throw new Error(`Refusing to fetch ${addr}`)\n  }\n  return parsed\n}\n```\n\nWhere `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.\n\nFor `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.","published":"2026-05-19T15:47:27Z","modified":"2026-05-19T16:00:09.068514821Z","cvss":{"score":8.2,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"auth-fetch-mcp","fixedVersion":"3.0.1"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/ymw0407/auth-fetch-mcp/security/advisories/GHSA-hv85-774v-26fg"},{"type":"PACKAGE","url":"https://github.com/ymw0407/auth-fetch-mcp"},{"type":"WEB","url":"https://github.com/ymw0407/auth-fetch-mcp/releases/tag/v3.0.1"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-05-19T16:00:09.068514821Z"}}