# REPRO-2026-00205: auth-fetch-mcp SSRF via IPv4-mapped IPv6 loopback bypass ## Summary Status: published Severity: high Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00205 CVE: CVE-2026-49857 ## Package Name: ymw0407/auth-fetch-mcp Ecosystem: npm Affected: <= 3.0.1 Fixed: 3.0.2 ## Root Cause # RCA Report: CVE-2026-49857 — auth-fetch-mcp SSRF via IPv4-mapped IPv6 loopback bypass ## Summary The `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. ## Impact - **Package/component affected**: `auth-fetch-mcp` npm package, specifically `src/security.ts` (`assertSafeUrl` → `isPrivateV6` → `isPrivateV4` guard chain) - **Affected versions**: ≤3.0.1 (fixed in 3.0.2) - **Risk level**: High - **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. ## Impact Parity - **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. - **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. - **Parity**: `full` - **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. ## Root Cause The vulnerability is in the `isPrivateV6()` function in `src/security.ts`: ```typescript function isPrivateV6(ip: string): boolean { const lower = ip.toLowerCase(); if (lower === "::" || lower === "::1") return true; if (lower.startsWith("fe80:") || lower.startsWith("fe80::")) return true; if (lower.startsWith("fc") || lower.startsWith("fd")) return true; if (lower.startsWith("ff")) return true; if (lower.startsWith("::ffff:")) { const v4 = lower.slice(7); if (net.isIPv4(v4)) return isPrivateV4(v4); // ← BUG: only handles dotted-decimal } return false; } ``` **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. **The call chain**: 1. MCP client sends `tools/call` with `download_media` and URL `http://[::ffff:127.0.0.1]:PORT/` 2. `download_media` handler calls `assertSafeUrl(url)` in `src/tools.ts:233` 3. `assertSafeUrl()` calls `new URL(rawUrl)` — the WHATWG parser normalizes `::ffff:127.0.0.1` → `::ffff:7f00:1` 4. `assertSafeUrl()` extracts `hostname` = `::ffff:7f00:1`, calls `isPrivateOrLinkLocal("::ffff:7f00:1")` 5. `isPrivateOrLinkLocal()` calls `isPrivateV6("::ffff:7f00:1")` (since `net.isIPv6()` returns `true`) 6. `isPrivateV6()` checks `::ffff:` prefix, extracts `7f00:1`, `net.isIPv4("7f00:1")` = `false` → **returns `false`** (bypass!) 7. `assertSafeUrl()` returns the parsed URL — security check passed 8. `ctx.request.get(safeUrl.toString())` fetches `http://[::ffff:7f00:1]:PORT/` → OS maps to `127.0.0.1:PORT` → **SSRF succeeds** **Fix commit**: `177ec5f8ee9c2d5749035777e562f699971b0da9` — adds hex group parsing to reconstruct the IPv4 address from the two trailing hex groups and run it through `isPrivateV4()`: ```typescript const groups = v4.split(":"); if (groups.length === 2 && groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) { const hi = parseInt(groups[0], 16); const lo = parseInt(groups[1], 16); const mapped = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; return isPrivateV4(mapped); } ``` ## Reproduction Steps 1. **Reference**: `bundle/repro/reproduction_steps.sh` (self-contained, uses `bundle/repro/mcp_client.js`) 2. **What the script does**: - Reads `bundle/project_cache_context.json` to locate the prepared project cache and Playwright browser cache - Clones/reuses the `auth-fetch-mcp` repo at the project cache path - 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) - Installs system libraries required by Chrome - Builds vulnerable version v3.0.1 (commit `98f381d`) - Starts a local "victim" HTTP server on `127.0.0.1:18080` that returns a unique secret marker - Spawns the real MCP server (`node dist/index.js`) as a child process - Sends JSON-RPC `initialize` + `tools/call download_media` with URL `http://[::ffff:127.0.0.1]:18080/` over stdio - Checks if the downloaded file contains the secret marker (SSRF confirmed) or if the response contains "Refusing to fetch" (blocked) - Repeats for fixed version v3.0.2 (commit `d4dedaf`, fix `177ec5f`) - Writes `runtime_manifest.json` with evidence 3. **Expected evidence**: - Vulnerable v3.0.1: victim server receives HTTP request from `127.0.0.1`, downloaded file contains the secret marker, tool returns `downloaded: 1` - Fixed v3.0.2: victim server receives no request, tool returns `error: "Refusing to fetch [::ffff:7f00:1]..."`, `downloaded: 0` ## Evidence - **Log files** (under `bundle/logs/`): - `reproduction_steps.log` — full script output - `vulnerable_test.log` — vulnerable version test output - `vulnerable_result.json` — structured result: `ssrfConfirmed: true`, `downloadedContent: "SSRF_SECRET_MARKER_..."` - `vulnerable_victim_server.log` — shows `Request from 127.0.0.1 path=/` (SSRF request received) - `vulnerable_mcp_stdout.log` — MCP server JSON-RPC responses - `vulnerable_mcp_requests.log` — JSON-RPC requests sent - `fixed_test.log` — fixed version test output - `fixed_result.json` — structured result: `blocked: true`, error: `"Refusing to fetch [::ffff:7f00:1]..."` - `fixed_victim_server.log` — shows NO request received (only "Listening" line) - `fixed_mcp_stdout.log` — MCP server JSON-RPC responses showing the block - **Key excerpts**: - Vulnerable victim server log: `[VICTIM:18080] Request from 127.0.0.1 path=/` - Vulnerable downloaded file: `SSRF_SECRET_MARKER_1783015602673_hgnjlkyh` (matches marker from internal server) - Vulnerable tool result: `{"status":"ok","downloaded":1,"total":1,"files":[{"url":"http://[::ffff:127.0.0.1]:18080/","localPath":".../file-1.bin","size":41}]}` - 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)..."}]}` - **Environment**: - Node.js v24.18.0, npm 11.16.0 - Ubuntu 26.04 LTS (Resolute Raccoon) - Playwright 1.58.2 with Chrome Headless Shell 145.0.7632.6 (manually installed) - MCP SDK `@modelcontextprotocol/sdk` ^1.27.1 (from package-lock.json) ## Recommendations / Next Steps - **Upgrade**: Update to `auth-fetch-mcp@3.0.2` or later, which includes the fix (commit `177ec5f`). - **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. - **Testing recommendations**: Add unit tests for `assertSafeUrl()` covering: - IPv4-mapped IPv6 in both dotted-decimal (`::ffff:127.0.0.1`) and hex (`::ffff:7f00:1`) forms - 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) - Public IPv4-mapped addresses should still pass (e.g., `::ffff:8.8.8.8`) - Integration tests that verify the MCP tool rejects loopback URLs end-to-end ## Additional Notes - **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. - **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`. - **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. - **Port choice**: The victim server uses port 18080 to avoid conflicts with common services. ## Reproduction Details Reproduced: 2026-07-02T19:34:14.770Z Duration: 1031 seconds Tool calls: 166 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00205 pruva-verify CVE-2026-49857 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00205&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00205/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-49857 - Source: https://nvd.nist.gov/vuln/detail/CVE-2026-49857 ## Artifacts - bundle/repro/reproduction_steps.sh (reproduction_script, 12310 bytes) - bundle/repro/rca_report.md (analysis, 10513 bytes) - bundle/vuln_variant/reproduction_steps.sh (reproduction_script, 10548 bytes) - bundle/vuln_variant/rca_report.md (analysis, 13144 bytes) - bundle/ticket.md (ticket, 968 bytes) - bundle/ticket.json (other, 1392 bytes) - bundle/repro/mcp_client.js (other, 6998 bytes) - bundle/repro/runtime_manifest.json (other, 1289 bytes) - bundle/repro/validation_verdict.json (other, 814 bytes) - bundle/logs/vulnerable_test.log (log, 2291 bytes) - bundle/logs/vulnerable_marker.txt (other, 41 bytes) - bundle/logs/vulnerable_victim_server.log (log, 314 bytes) - bundle/logs/vulnerable_mcp_requests.log (log, 722 bytes) - bundle/logs/vulnerable_mcp_stdout.log (log, 2694 bytes) - bundle/logs/vulnerable_result.json (other, 1014 bytes) - bundle/logs/fixed_test.log (log, 2013 bytes) - bundle/logs/fixed_marker.txt (other, 41 bytes) - bundle/logs/fixed_victim_server.log (log, 174 bytes) - bundle/logs/fixed_mcp_requests.log (log, 722 bytes) - bundle/logs/fixed_mcp_stdout.log (log, 2730 bytes) - bundle/logs/fixed_result.json (other, 840 bytes) - bundle/logs/reproduction_steps.log (log, 7494 bytes) - bundle/logs/mcp-home-vulnerable/.auth-fetch-mcp/downloads/2026-07-02T18-06-43/file-1.bin (other, 41 bytes) - bundle/logs/vuln_variant/probe_guard.log (log, 3607 bytes) - bundle/logs/vuln_variant/routing_test.log (log, 1632 bytes) - bundle/logs/vuln_variant/routing_test.json (other, 1868 bytes) - bundle/logs/vuln_variant/redirect_tier1.log (log, 658 bytes) - bundle/logs/vuln_variant/redirect_tier1.json (other, 671 bytes) - bundle/logs/vuln_variant/fixed_variant_test.log (log, 1548 bytes) - bundle/logs/vuln_variant/fixed-variant_marker.txt (other, 42 bytes) - bundle/logs/vuln_variant/mcp-home-vuln-variant/.auth-fetch-mcp/downloads/2026-07-02T18-15-57/file-1.bin (other, 42 bytes) - bundle/logs/vuln_variant/fixed_variant_result.json (other, 1577 bytes) - bundle/logs/vuln_variant/main_variant_test.log (log, 1547 bytes) - bundle/logs/vuln_variant/main-variant_marker.txt (other, 42 bytes) - bundle/logs/vuln_variant/main-variant_victim_server.log (log, 895 bytes) - bundle/logs/vuln_variant/main-variant_mcp_requests.log (log, 2290 bytes) - bundle/logs/vuln_variant/main_variant_result.json (other, 1574 bytes) - bundle/logs/vuln_variant/vuln_variant_test.log (log, 1547 bytes) - bundle/logs/vuln_variant/vuln-variant_marker.txt (other, 42 bytes) - bundle/logs/vuln_variant/vuln-variant_victim_server.log (log, 895 bytes) - bundle/logs/vuln_variant/vuln-variant_mcp_requests.log (log, 2290 bytes) - bundle/logs/vuln_variant/vuln_variant_result.json (other, 1574 bytes) - bundle/logs/vuln_variant/fixed-variant_victim_server.log (log, 895 bytes) - bundle/logs/vuln_variant/fixed-variant_mcp_requests.log (log, 2290 bytes) - bundle/logs/vuln_variant/reproduction_steps.log (log, 7728 bytes) - bundle/logs/vuln_variant/vuln-variant_variant_test.log (log, 1547 bytes) - bundle/logs/vuln_variant/vuln-variant_variant_result.json (other, 1574 bytes) - bundle/logs/vuln_variant/fixed-variant_variant_test.log (log, 1548 bytes) - bundle/logs/vuln_variant/fixed-variant_variant_result.json (other, 1577 bytes) - bundle/logs/vuln_variant/main-variant_variant_test.log (log, 1547 bytes) - bundle/logs/vuln_variant/main-variant_variant_result.json (other, 1574 bytes) - bundle/logs/vuln_variant/final_run.log (log, 7728 bytes) - bundle/logs/vuln_variant/mcp-home-fixed-variant/.auth-fetch-mcp/downloads/2026-07-02T18-15-59/file-1.bin (other, 42 bytes) - bundle/logs/vuln_variant/mcp-home-main-variant/.auth-fetch-mcp/downloads/2026-07-02T18-16-02/file-1.bin (other, 42 bytes) - bundle/vuln_variant/probe_guard.js (other, 5034 bytes) - bundle/vuln_variant/probe_routing.js (other, 3047 bytes) - bundle/vuln_variant/redirect_tier1.js (other, 3680 bytes) - bundle/vuln_variant/variant_mcp_client.js (other, 8881 bytes) - bundle/vuln_variant/runtime_manifest.json (other, 1538 bytes) - bundle/vuln_variant/patch_analysis.md (documentation, 8777 bytes) - bundle/vuln_variant/variant_manifest.json (other, 5592 bytes) - bundle/vuln_variant/validation_verdict.json (other, 4451 bytes) - bundle/vuln_variant/source_identity.json (other, 2821 bytes) - bundle/vuln_variant/root_cause_equivalence.json (other, 4743 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00205 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00205/artifacts/bundle/repro/reproduction_steps.sh - Web: https://pruva.dev/r/REPRO-2026-00205 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev