{"repro_id":"REPRO-2026-00205","version":8,"title":"auth-fetch-mcp SSRF via IPv4-mapped IPv6 loopback bypass","repro_type":"security","status":"published","severity":"high","description":"Target repo: https://github.com/ymw0407/auth-fetch-mcp. Vulnerable package: auth-fetch-mcp (npm). Affected versions: <=3.0.1; fixed in 3.0.2. assertSafeUrl() in src/security.ts calls isPrivateV6() which checks for ::ffff: and then net.isIPv4() on the suffix. The Node.js WHATWG URL parser hex-normalizes ::ffff:127.0.0.1 to ::ffff:7f00:1, so net.isIPv4('7f00:1') returns false and the loopback address bypasses the private-IP guard. Reproduction: install auth-fetch-mcp@3.0.1, run the MCP server with default settings, and invoke the auth_fetch or download_media tool with URL http://[::ffff:127.0.0.1]:<PORT>/. The server will fetch the loopback URL and return the response, confirming SSRF. The advisory provides a detailed trace through src/tools.ts, src/browser.ts, and src/extractor.ts.","root_cause":"# RCA Report: CVE-2026-49857 — auth-fetch-mcp SSRF via IPv4-mapped IPv6 loopback bypass\n\n## Summary\n\nThe `auth-fetch-mcp` MCP server (npm package `auth-fetch-mcp`, versions ≤3.0.1) contains a Server-Side Request Forgery (SSRF) vulnerability in its URL security guard. The `assertSafeUrl()` function in `src/security.ts` is designed to block requests to private/loopback IP addresses, but its `isPrivateV6()` helper fails to detect IPv4-mapped IPv6 addresses after the Node.js WHATWG URL parser hex-normalizes them. When a user supplies a URL like `http://[::ffff:127.0.0.1]:PORT/`, the URL parser normalizes the hostname to `::ffff:7f00:1` (hex form). The guard checks `net.isIPv4(\"7f00:1\")`, which returns `false` because the suffix is in hex notation, not dotted-decimal. As a result, the loopback address is classified as non-private and the security guard is bypassed, allowing the MCP server to fetch arbitrary internal/loopback URLs via the `download_media` and `auth_fetch` tools.\n\n## Impact\n\n- **Package/component affected**: `auth-fetch-mcp` npm package, specifically `src/security.ts` (`assertSafeUrl` → `isPrivateV6` → `isPrivateV4` guard chain)\n- **Affected versions**: ≤3.0.1 (fixed in 3.0.2)\n- **Risk level**: High\n- **Consequences**: An attacker (via a malicious MCP client or prompt injection) can cause the MCP server to fetch arbitrary internal/loopback URLs, bypassing the SSRF protection. The `download_media` tool downloads the fetched content to disk and returns the file path to the caller, enabling information disclosure from internal services (e.g., cloud metadata endpoints at `169.254.169.254`, internal APIs on `127.0.0.1`/`10.x`/`192.168.x`, etc.). The `auth_fetch` tool renders fetched internal pages in a browser and returns extracted content.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact**: SSRF — bypass of the private-IP guard to fetch internal/loopback URLs via the MCP server's `auth_fetch` or `download_media` tools.\n- **Reproduced impact from this run**: Full SSRF confirmed. The MCP server (v3.0.1) processed a `download_media` tool call with URL `http://[::ffff:127.0.0.1]:18080/`, bypassed `assertSafeUrl()`, fetched content from a loopback HTTP server via Playwright's `ctx.request.get()`, saved the response to disk, and returned the file path. The downloaded file contained the secret marker from the internal server, proving the server-side request reached the loopback target.\n- **Parity**: `full`\n- **Not demonstrated**: The reproduction targeted a loopback HTTP server (`127.0.0.1:18080`). Cloud metadata endpoint (`169.254.169.254`) and other private ranges were not exercised at runtime, but the same bypass mechanism applies to all private IPv4 ranges via their IPv4-mapped IPv6 hex forms.\n\n## Root Cause\n\nThe vulnerability is in the `isPrivateV6()` function in `src/security.ts`:\n\n```typescript\nfunction isPrivateV6(ip: string): boolean {\n  const lower = ip.toLowerCase();\n  if (lower === \"::\" || lower === \"::1\") return true;\n  if (lower.startsWith(\"fe80:\") || lower.startsWith(\"fe80::\")) return true;\n  if (lower.startsWith(\"fc\") || lower.startsWith(\"fd\")) return true;\n  if (lower.startsWith(\"ff\")) return true;\n  if (lower.startsWith(\"::ffff:\")) {\n    const v4 = lower.slice(7);\n    if (net.isIPv4(v4)) return isPrivateV4(v4);  // ← BUG: only handles dotted-decimal\n  }\n  return false;\n}\n```\n\n**The bug**: When the WHATWG URL parser processes `http://[::ffff:127.0.0.1]:PORT/`, it hex-normalizes the IPv4-mapped IPv6 hostname to `::ffff:7f00:1` (where `7f` = 127, `00` = 0, `01` = 1). The `isPrivateV6()` function extracts the suffix `7f00:1` and checks `net.isIPv4(\"7f00:1\")`, which returns `false` because the suffix is in hex notation, not dotted-decimal form. The function then falls through to `return false`, classifying the loopback address as non-private.\n\n**The call chain**:\n1. MCP client sends `tools/call` with `download_media` and URL `http://[::ffff:127.0.0.1]:PORT/`\n2. `download_media` handler calls `assertSafeUrl(url)` in `src/tools.ts:233`\n3. `assertSafeUrl()` calls `new URL(rawUrl)` — the WHATWG parser normalizes `::ffff:127.0.0.1` → `::ffff:7f00:1`\n4. `assertSafeUrl()` extracts `hostname` = `::ffff:7f00:1`, calls `isPrivateOrLinkLocal(\"::ffff:7f00:1\")`\n5. `isPrivateOrLinkLocal()` calls `isPrivateV6(\"::ffff:7f00:1\")` (since `net.isIPv6()` returns `true`)\n6. `isPrivateV6()` checks `::ffff:` prefix, extracts `7f00:1`, `net.isIPv4(\"7f00:1\")` = `false` → **returns `false`** (bypass!)\n7. `assertSafeUrl()` returns the parsed URL — security check passed\n8. `ctx.request.get(safeUrl.toString())` fetches `http://[::ffff:7f00:1]:PORT/` → OS maps to `127.0.0.1:PORT` → **SSRF succeeds**\n\n**Fix commit**: `177ec5f8ee9c2d5749035777e562f699971b0da9` — adds hex group parsing to reconstruct the IPv4 address from the two trailing hex groups and run it through `isPrivateV4()`:\n\n```typescript\nconst groups = v4.split(\":\");\nif (groups.length === 2 && groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) {\n  const hi = parseInt(groups[0], 16);\n  const lo = parseInt(groups[1], 16);\n  const mapped = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;\n  return isPrivateV4(mapped);\n}\n```\n\n## Reproduction Steps\n\n1. **Reference**: `bundle/repro/reproduction_steps.sh` (self-contained, uses `bundle/repro/mcp_client.js`)\n2. **What the script does**:\n   - Reads `bundle/project_cache_context.json` to locate the prepared project cache and Playwright browser cache\n   - Clones/reuses the `auth-fetch-mcp` repo at the project cache path\n   - Installs Chrome Headless Shell for Playwright (manual download from Google's Chrome for Testing CDN, since `npx playwright install` doesn't support Ubuntu 26.04)\n   - Installs system libraries required by Chrome\n   - Builds vulnerable version v3.0.1 (commit `98f381d`)\n   - Starts a local \"victim\" HTTP server on `127.0.0.1:18080` that returns a unique secret marker\n   - Spawns the real MCP server (`node dist/index.js`) as a child process\n   - Sends JSON-RPC `initialize` + `tools/call download_media` with URL `http://[::ffff:127.0.0.1]:18080/` over stdio\n   - Checks if the downloaded file contains the secret marker (SSRF confirmed) or if the response contains \"Refusing to fetch\" (blocked)\n   - Repeats for fixed version v3.0.2 (commit `d4dedaf`, fix `177ec5f`)\n   - Writes `runtime_manifest.json` with evidence\n3. **Expected evidence**:\n   - Vulnerable v3.0.1: victim server receives HTTP request from `127.0.0.1`, downloaded file contains the secret marker, tool returns `downloaded: 1`\n   - Fixed v3.0.2: victim server receives no request, tool returns `error: \"Refusing to fetch [::ffff:7f00:1]...\"`, `downloaded: 0`\n\n## Evidence\n\n- **Log files** (under `bundle/logs/`):\n  - `reproduction_steps.log` — full script output\n  - `vulnerable_test.log` — vulnerable version test output\n  - `vulnerable_result.json` — structured result: `ssrfConfirmed: true`, `downloadedContent: \"SSRF_SECRET_MARKER_...\"`\n  - `vulnerable_victim_server.log` — shows `Request from 127.0.0.1 path=/` (SSRF request received)\n  - `vulnerable_mcp_stdout.log` — MCP server JSON-RPC responses\n  - `vulnerable_mcp_requests.log` — JSON-RPC requests sent\n  - `fixed_test.log` — fixed version test output\n  - `fixed_result.json` — structured result: `blocked: true`, error: `\"Refusing to fetch [::ffff:7f00:1]...\"`\n  - `fixed_victim_server.log` — shows NO request received (only \"Listening\" line)\n  - `fixed_mcp_stdout.log` — MCP server JSON-RPC responses showing the block\n\n- **Key excerpts**:\n  - Vulnerable victim server log: `[VICTIM:18080] Request from 127.0.0.1 path=/`\n  - Vulnerable downloaded file: `SSRF_SECRET_MARKER_1783015602673_hgnjlkyh` (matches marker from internal server)\n  - Vulnerable tool result: `{\"status\":\"ok\",\"downloaded\":1,\"total\":1,\"files\":[{\"url\":\"http://[::ffff:127.0.0.1]:18080/\",\"localPath\":\".../file-1.bin\",\"size\":41}]}`\n  - Fixed tool result: `{\"status\":\"ok\",\"downloaded\":0,\"total\":1,\"files\":[{\"url\":\"http://[::ffff:127.0.0.1]:18080/\",\"error\":\"Refusing to fetch [::ffff:7f00:1] (resolves to private/loopback/link-local address ::ffff:7f00:1)...\"}]}`\n\n- **Environment**:\n  - Node.js v24.18.0, npm 11.16.0\n  - Ubuntu 26.04 LTS (Resolute Raccoon)\n  - Playwright 1.58.2 with Chrome Headless Shell 145.0.7632.6 (manually installed)\n  - MCP SDK `@modelcontextprotocol/sdk` ^1.27.1 (from package-lock.json)\n\n## Recommendations / Next Steps\n\n- **Upgrade**: Update to `auth-fetch-mcp@3.0.2` or later, which includes the fix (commit `177ec5f`).\n- **Suggested fix approach** (already implemented in 3.0.2): Parse the two trailing hex groups of `::ffff:` prefixed IPv6 addresses back into dotted-decimal IPv4 form and run through `isPrivateV4()`. Additionally, consider using a well-maintained SSRF protection library (e.g., `ssrf-check` or equivalent) rather than custom IP range checks.\n- **Testing recommendations**: Add unit tests for `assertSafeUrl()` covering:\n  - IPv4-mapped IPv6 in both dotted-decimal (`::ffff:127.0.0.1`) and hex (`::ffff:7f00:1`) forms\n  - All private ranges: `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` (link-local/metadata)\n  - Public IPv4-mapped addresses should still pass (e.g., `::ffff:8.8.8.8`)\n  - Integration tests that verify the MCP tool rejects loopback URLs end-to-end\n\n## Additional Notes\n\n- **Idempotency**: The script was run twice consecutively with identical results (both runs: vulnerable=SSRF confirmed, fixed=blocked). The script cleans up previous test state (removes old HOME directories, overwrites result files) and uses unique markers per run.\n- **Chrome installation workaround**: Playwright 1.58.2 does not support `npx playwright install` on Ubuntu 26.04. The script manually downloads Chrome Headless Shell and Chrome for Testing from Google's CDN (`storage.googleapis.com/chrome-for-testing-public/`) and places them in the Playwright browser cache directory. The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set so Playwright finds the manually installed browser regardless of `HOME`.\n- **MCP transport**: The auth-fetch-mcp server uses stdio JSON-RPC (StdioServerTransport), not HTTP. The reproduction interacts with it via its real JSON-RPC API by spawning the server process and sending protocol messages over stdin/stdout. This is the actual API surface of the product — the tool-calling endpoint that processes attacker-supplied URLs.\n- **Port choice**: The victim server uses port 18080 to avoid conflicts with common services.\n","cve_id":"CVE-2026-49857","cwe_id":"CWE-918 (SSRF)","source_url":"https://nvd.nist.gov/vuln/detail/CVE-2026-49857","package":{"name":"ymw0407/auth-fetch-mcp","ecosystem":"npm","affected_versions":"<= 3.0.1","fixed_version":"3.0.2"},"reproduced_at":"2026-07-02T19:34:14.770685+00:00","duration_secs":1031.0,"tool_calls":166,"handoffs":2,"total_cost_usd":2.59789755,"agent_costs":{"hypothesis_generator":0.0114172,"judge":0.01078505,"repro":1.21653924,"support":0.07010097,"vuln_variant":1.28905509},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/glm-5p2":0.0114172},"judge":{"gpt-5.4-mini":0.01078505},"repro":{"accounts/fireworks/routers/glm-5p2-fast":1.21653924},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.07010097},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":1.28905509}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-02T19:34:15.676644+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":12310,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":10513,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":10548,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":13144,"category":"analysis"},{"path":"bundle/ticket.md","filename":"ticket.md","size":968,"category":"ticket"},{"path":"bundle/ticket.json","filename":"ticket.json","size":1392,"category":"other"},{"path":"bundle/repro/mcp_client.js","filename":"mcp_client.js","size":6998,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1289,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":814,"category":"other"},{"path":"bundle/logs/vulnerable_test.log","filename":"vulnerable_test.log","size":2291,"category":"log"},{"path":"bundle/logs/vulnerable_marker.txt","filename":"vulnerable_marker.txt","size":41,"category":"other"},{"path":"bundle/logs/vulnerable_victim_server.log","filename":"vulnerable_victim_server.log","size":314,"category":"log"},{"path":"bundle/logs/vulnerable_mcp_requests.log","filename":"vulnerable_mcp_requests.log","size":722,"category":"log"},{"path":"bundle/logs/vulnerable_mcp_stdout.log","filename":"vulnerable_mcp_stdout.log","size":2694,"category":"log"},{"path":"bundle/logs/vulnerable_result.json","filename":"vulnerable_result.json","size":1014,"category":"other"},{"path":"bundle/logs/fixed_test.log","filename":"fixed_test.log","size":2013,"category":"log"},{"path":"bundle/logs/fixed_marker.txt","filename":"fixed_marker.txt","size":41,"category":"other"},{"path":"bundle/logs/fixed_victim_server.log","filename":"fixed_victim_server.log","size":174,"category":"log"},{"path":"bundle/logs/fixed_mcp_requests.log","filename":"fixed_mcp_requests.log","size":722,"category":"log"},{"path":"bundle/logs/fixed_mcp_stdout.log","filename":"fixed_mcp_stdout.log","size":2730,"category":"log"},{"path":"bundle/logs/fixed_result.json","filename":"fixed_result.json","size":840,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":7494,"category":"log"},{"path":"bundle/logs/mcp-home-vulnerable/.auth-fetch-mcp/downloads/2026-07-02T18-06-43/file-1.bin","filename":"file-1.bin","size":41,"category":"other"},{"path":"bundle/logs/vuln_variant/probe_guard.log","filename":"probe_guard.log","size":3607,"category":"log"},{"path":"bundle/logs/vuln_variant/routing_test.log","filename":"routing_test.log","size":1632,"category":"log"},{"path":"bundle/logs/vuln_variant/routing_test.json","filename":"routing_test.json","size":1868,"category":"other"},{"path":"bundle/logs/vuln_variant/redirect_tier1.log","filename":"redirect_tier1.log","size":658,"category":"log"},{"path":"bundle/logs/vuln_variant/redirect_tier1.json","filename":"redirect_tier1.json","size":671,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_variant_test.log","filename":"fixed_variant_test.log","size":1548,"category":"log"},{"path":"bundle/logs/vuln_variant/fixed-variant_marker.txt","filename":"fixed-variant_marker.txt","size":42,"category":"other"},{"path":"bundle/logs/vuln_variant/mcp-home-vuln-variant/.auth-fetch-mcp/downloads/2026-07-02T18-15-57/file-1.bin","filename":"file-1.bin","size":42,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_variant_result.json","filename":"fixed_variant_result.json","size":1577,"category":"other"},{"path":"bundle/logs/vuln_variant/main_variant_test.log","filename":"main_variant_test.log","size":1547,"category":"log"},{"path":"bundle/logs/vuln_variant/main-variant_marker.txt","filename":"main-variant_marker.txt","size":42,"category":"other"},{"path":"bundle/logs/vuln_variant/main-variant_victim_server.log","filename":"main-variant_victim_server.log","size":895,"category":"log"},{"path":"bundle/logs/vuln_variant/main-variant_mcp_requests.log","filename":"main-variant_mcp_requests.log","size":2290,"category":"log"},{"path":"bundle/logs/vuln_variant/main_variant_result.json","filename":"main_variant_result.json","size":1574,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_variant_test.log","filename":"vuln_variant_test.log","size":1547,"category":"log"},{"path":"bundle/logs/vuln_variant/vuln-variant_marker.txt","filename":"vuln-variant_marker.txt","size":42,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln-variant_victim_server.log","filename":"vuln-variant_victim_server.log","size":895,"category":"log"},{"path":"bundle/logs/vuln_variant/vuln-variant_mcp_requests.log","filename":"vuln-variant_mcp_requests.log","size":2290,"category":"log"},{"path":"bundle/logs/vuln_variant/vuln_variant_result.json","filename":"vuln_variant_result.json","size":1574,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed-variant_victim_server.log","filename":"fixed-variant_victim_server.log","size":895,"category":"log"},{"path":"bundle/logs/vuln_variant/fixed-variant_mcp_requests.log","filename":"fixed-variant_mcp_requests.log","size":2290,"category":"log"},{"path":"bundle/logs/vuln_variant/reproduction_steps.log","filename":"reproduction_steps.log","size":7728,"category":"log"},{"path":"bundle/logs/vuln_variant/vuln-variant_variant_test.log","filename":"vuln-variant_variant_test.log","size":1547,"category":"log"},{"path":"bundle/logs/vuln_variant/vuln-variant_variant_result.json","filename":"vuln-variant_variant_result.json","size":1574,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed-variant_variant_test.log","filename":"fixed-variant_variant_test.log","size":1548,"category":"log"},{"path":"bundle/logs/vuln_variant/fixed-variant_variant_result.json","filename":"fixed-variant_variant_result.json","size":1577,"category":"other"},{"path":"bundle/logs/vuln_variant/main-variant_variant_test.log","filename":"main-variant_variant_test.log","size":1547,"category":"log"},{"path":"bundle/logs/vuln_variant/main-variant_variant_result.json","filename":"main-variant_variant_result.json","size":1574,"category":"other"},{"path":"bundle/logs/vuln_variant/final_run.log","filename":"final_run.log","size":7728,"category":"log"},{"path":"bundle/logs/vuln_variant/mcp-home-fixed-variant/.auth-fetch-mcp/downloads/2026-07-02T18-15-59/file-1.bin","filename":"file-1.bin","size":42,"category":"other"},{"path":"bundle/logs/vuln_variant/mcp-home-main-variant/.auth-fetch-mcp/downloads/2026-07-02T18-16-02/file-1.bin","filename":"file-1.bin","size":42,"category":"other"},{"path":"bundle/vuln_variant/probe_guard.js","filename":"probe_guard.js","size":5034,"category":"other"},{"path":"bundle/vuln_variant/probe_routing.js","filename":"probe_routing.js","size":3047,"category":"other"},{"path":"bundle/vuln_variant/redirect_tier1.js","filename":"redirect_tier1.js","size":3680,"category":"other"},{"path":"bundle/vuln_variant/variant_mcp_client.js","filename":"variant_mcp_client.js","size":8881,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1538,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":8777,"category":"documentation"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":5592,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":4451,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":2821,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":4743,"category":"other"}]}