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