# Variant RCA Report — CVE-2026-49857 Redirect-Following SSRF Bypass

## Summary

A distinct, working Server-Side Request Forgery (SSRF) bypass of the CVE-2026-49857 fix exists in `auth-fetch-mcp` and reproduces end-to-end on the **fixed** v3.0.2 server and on the **latest** default branch (`origin/main`, `a4b9245`). The fix (commit `177ec5f`) only hardened `isPrivateV6()` against the IPv4-mapped IPv6 hex-normalization vector. It did **not** change the fact that `assertSafeUrl()` is applied only to the **initial** request URL. Playwright's `ctx.request.get()` (used by `download_media` at `src/tools.ts:234`) and `page.goto()` (used by `auth_fetch` via `src/browser.ts:66`) follow HTTP 3xx redirects to private/loopback IPs **without re-running `assertSafeUrl()` on the redirect target**. An attacker supplies a **public** URL that 302-redirects to a private URL (e.g. `http://httpbin.org/redirect-to?url=http://127.0.0.1:PORT/`); the guard passes the public host, the redirect is followed to the loopback target with no re-validation, and the internal response is returned to the caller. This is a true bypass: the same SSRF guard, same tools, same sink, same trust boundary — a gap the fix does not cover.

## Fix Coverage / Assumptions

- **Invariant the fix relies on**: the URL that passes `assertSafeUrl()` is the URL that gets fetched. The fix only improves the IP *classification* inside `isPrivateV6()`; it never touches the fetch layer or redirect handling.
- **Code paths explicitly covered**: `src/security.ts` `isPrivateV6()` for `::ffff:`-prefixed IPv4-**mapped** addresses that the WHATWG parser normalizes to exactly two trailing hex groups (`::ffff:7f00:1`).
- **What the fix does NOT cover**:
  1. **Redirect targets** — `assertSafeUrl()` is not invoked on `Location` targets of 3xx responses. *(confirmed bypass)*
  2. Non-mapped IPv4-in-IPv6 forms (`::ffff:0:a.b.c.d`, `::a.b.c.d`, `64:ff9b::a.b.c.d`) bypass the guard's string check, but the OS does not route them to IPv4 loopback in this environment, so no working SSRF *(guard incompleteness only)*.
  3. DNS rebinding / TOCTOU between the guard's `dns.lookup` and the fetch's resolution *(separate root cause)*.

## Variant / Alternate Trigger

- **Entry point**: the `download_media` MCP tool (and equivalently `auth_fetch`) over the real stdio JSON-RPC API — the product's actual tool-calling surface. A malicious MCP client or prompt-injected instruction calls `tools/call download_media` with:
  ```
  urls: [ "http://httpbin.org/redirect-to?url=http%3A%2F%2F127.0.0.1%3A18080%2F&status_code=302" ]
  ```
- **Why it bypasses the fix**: `httpbin.org` resolves to **public** IPs only, so `assertSafeUrl()` (`src/security.ts:87`) passes it. The fixed `isPrivateV6()` is never even reached for a private address at guard time. `httpbin.org` responds `302 Location: http://127.0.0.1:18080/`. `ctx.request.get(safeUrl.toString())` at `src/tools.ts:234` follows the redirect to the loopback target and downloads its body. `assertSafeUrl()` is **not** called again. The downloaded file is written to disk and its path/content returned to the caller.
- **Code path**: `tools.ts:233 assertSafeUrl(url)` → passes (public) → `tools.ts:234 ctx.request.get(safeUrl)` → 302 → `ctx.request.get` follows → reaches `127.0.0.1:18080` (loopback victim) → `tools.ts` writes `file-1.bin` → returns `{downloaded:1, files:[{localPath, size}]}`. The sibling control URL `http://127.0.0.1:18080/direct-control` in the same call is correctly rejected with `"Refusing to fetch 127.0.0.1 ..."`, proving the guard works for direct private URLs but not for redirect targets.

## Impact

- **Package/component**: `auth-fetch-mcp` npm package; SSRF guard in `src/security.ts` + fetch layer in `src/tools.ts` (`download_media`) and `src/browser.ts` (`auth_fetch`/`navigateTo`).
- **Affected versions (as tested)**: v3.0.1 (`98f381d`, vulnerable), **v3.0.2 (`d4dedaf`, fixed)**, and **`origin/main` (`a4b9245`, latest)** — bypass reproduces on all three.
- **Risk level**: High. An attacker can cause the MCP server to fetch arbitrary internal/loopback URLs (e.g. cloud metadata `169.254.169.254`, internal APIs on `127.0.0.1`/`10.x`/`192.168.x`) by pointing a public URL at them via redirect, then exfiltrate the response. `download_media` returns the fetched bytes to the caller (file path + content); `auth_fetch` renders and returns extracted page content.

## Impact Parity

- **Disclosed/claimed maximum impact** (parent CVE): SSRF — bypass of the private-IP guard to fetch internal/loopback URLs via `auth_fetch`/`download_media`.
- **Reproduced impact from this variant run**: Full SSRF confirmed on the **fixed** server. The v3.0.2 MCP server processed `download_media` with the public 302-redirect URL, followed the redirect to `http://127.0.0.1:18080/`, the internal victim server received the request (`Request from 127.0.0.1 path=/ host=127.0.0.1:18080`), the response body (a unique secret marker) was downloaded to disk and returned to the caller (`downloaded: 1`, `variant_redirect.ssrf: true`, `downloadedContent: "VARIANT_SSRF_MARKER_..."`).
- **Parity**: `full` — same impact class (SSRF to loopback via the same guard/tools/sink) as the parent, achieved on the patched code.
- **Not demonstrated**: Cloud metadata endpoint (`169.254.169.254`) and other private ranges were not exercised at runtime (no such endpoint in the sandbox), but the identical mechanism applies — any private IP reachable from the server can be the redirect target.

## Root Cause

`assertSafeUrl()` (`src/security.ts:87`) is the product's SSRF gate, but it is invoked **once** on the caller-supplied URL (`tools.ts:233`, `browser.ts:58`). The downstream fetch (`ctx.request.get` / `page.goto`) follows redirects without re-invoking the gate. The CVE-2026-49857 fix (`177ec5f`) narrowed its change to the IP-classification helper `isPrivateV6()` and did not address redirect-following. Consequently, on the fixed code, any public URL that 3xx-redirects to a private/loopback URL produces SSRF: the guard sees only the public host, and the redirect target is fetched unvalidated. This is a gap in the **same** SSRF protection the fix belongs to (same guard, same tools, same sink, same trust boundary), reached via a different mechanism (redirect-following) than the original (IPv4-mapped IPv6 hex normalization). Fix commit: `177ec5f8ee9c2d5749035777e562f699971b0da9`.

## Reproduction Steps

1. **Reference**: `bundle/vuln_variant/reproduction_steps.sh` (self-contained; uses `bundle/vuln_variant/variant_mcp_client.js`).
2. **What the script does**:
   - Locates the prepared project cache (`bundle/project_cache_context.json`) for the repo + Playwright browser cache.
   - Creates `git worktree`s for `v3.0.1` (vulnerable), `v3.0.2` (fixed), and `origin/main` (latest) under `bundle/artifacts/wt-*`, reusing the cache's `node_modules` via symlink, and builds each (`npm run build`).
   - For each version, starts a loopback "victim" HTTP server on `127.0.0.1:18080` returning a unique secret marker, spawns the real MCP server (`node dist/index.js`), and sends JSON-RPC `initialize` + `tools/call download_media` with two URLs: a control `http://127.0.0.1:18080/direct-control` (must be blocked) and the variant `http://httpbin.org/redirect-to?url=http://127.0.0.1:18080/&status_code=302` (public → 302 → loopback).
   - Verifies per version: control blocked by the guard, variant reached the loopback victim and downloaded the marker.
   - Writes `bundle/vuln_variant/runtime_manifest.json` and per-version `*_variant_result.json` under `bundle/logs/vuln_variant/`.
3. **Expected evidence**:
   - Fixed v3.0.2: `control_direct_loopback.blocked = true` AND `variant_redirect.ssrf = true` (marker present in downloaded file) AND victim log shows `Request from 127.0.0.1 path=/`.
   - Latest main: same. Vulnerable v3.0.1: same.
   - Script exits 0 (bypass reproduced on the fixed version).

## Evidence

- **Logs** under `bundle/logs/vuln_variant/`:
  - `reproduction_steps.log` — full script output (both idempotent runs).
  - `fixed-variant_variant_result.json` — fixed v3.0.2: `ssrfConfirmed:true`, `control_direct_loopback.blocked:true`, `variant_redirect.ssrf:true`, `downloadedContent:"VARIANT_SSRF_MARKER_..."`, `victim_hits:[{remote:"127.0.0.1",path:"/",host:"127.0.0.1:18080"}]`.
  - `fixed-variant_victim_server.log` — `[VICTIM:18080] Request from 127.0.0.1 path=/ host=127.0.0.1:18080`.
  - `fixed-variant_variant_test.log` — MCP JSON-RPC trace; control file error `"Refusing to fetch 127.0.0.1 ..."`, variant file `localPath`/`size:42` (downloaded marker).
  - `main-variant_variant_result.json` / `vuln-variant_variant_result.json` — same outcome on latest main and on vulnerable v3.0.1.
  - `redirect_tier1.log` / `redirect_tier1.json` — mechanism proof with Playwright `request.newContext().get()` (the same HTTP client as `ctx.request.get`): `assertSafeUrl(initial)` PASS, `ctx.request.get` followed 302 to `http://127.0.0.1:18080/`, `REDIRECT_FOLLOWED_TO_LOOPBACK: true`, `MARKER_IN_BODY: true`.
  - `probe_guard.log` — guard-logic matrix across candidate addresses (shows `::ffff:0:127.0.0.1` etc. bypass the fixed guard's string check).
  - `routing_test.log` — empirical TCP routing test (shows non-mapped IPv4-in-IPv6 forms are `ENETUNREACH`, i.e. not a working SSRF).
- **Key excerpts** (fixed v3.0.2):
  - Tool result: `{"status":"ok","downloaded":1,"total":2,"files":[{"url":"http://127.0.0.1:18080/direct-control","error":"Refusing to fetch 127.0.0.1 (resolves to private/loopback/link-local address 127.0.0.1)..."},{"url":"http://httpbin.org/redirect-to?...#v","localPath":".../file-1.bin","size":42}]}`
  - Victim log: `[VICTIM:18080] Request from 127.0.0.1 path=/ host=127.0.0.1:18080`
- **Environment**: Node v24.18.0; Playwright chromium headless shell-1208; Ubuntu container (eth0 172.19.0.7); outbound internet enabled (httpbin.org resolves to public IPs and returns 302).

## Recommendations / Next Steps

To close the gap and ship a complete SSRF fix:

1. **Re-validate every fetched URL, including redirect targets.** Preferred options:
   - Disable auto-redirect at the guard boundary and resolve redirects manually, calling `assertSafeUrl()` on each `Location` before following it; or
   - Wrap `ctx.request.get()`/`page.goto()` so that on each redirect the target URL is passed back through `assertSafeUrl()`; or
   - Pin the connection to the resolved IP from the guard's `dns.lookup` and reject any redirect whose target hostname resolves to a private/loopback/link-local address.
2. **Broaden `isPrivateV6()` IPv4-in-IPv6 handling** (defense in depth): reject all IPv4-embedding forms — mapped (`::ffff:x:y`), translated (`::ffff:0:x:y`), compatible (`::x:y`), and NAT64 (`64:ff9b::/96`, `64:ff9b:1::/96`) — by parsing with a real IP library rather than the `groups.length === 2` heuristic. Reconstruct and run `isPrivateV4()` on the embedded IPv4 for each recognized prefix.
3. **Fix the link-local prefix check**: use `fe80::/10` (match `fe80:`–`fef0:`) instead of only `fe80:`.
4. **Address DNS rebinding**: consider resolving once, pinning the address, and connecting to the IP directly with the original `Host` header (or re-resolving and re-checking at fetch time).
5. **Add tests**: end-to-end SSRF tests covering (a) public-URL-redirects-to-private for both `download_media` and `auth_fetch`, (b) all IPv4-in-IPv6 forms, (c) all private ranges.

## Additional Notes

- **Idempotency**: `reproduction_steps.sh` was run twice consecutively; both runs completed with identical results (`vuln=true`, `fixed=true`, `main=true`, exit 0). Worktrees and dist are reused across runs; per-run state (HOME dirs, markers, result files) is overwritten each run.
- **Bypass vs alternate trigger**: this is a **bypass** — it reproduces on the patched/fixed code path (v3.0.2 and latest main), not only on the vulnerable version.
- **Trust boundary**: the malicious URL crosses the MCP client→server trust boundary (tool argument over the JSON-RPC API); the server then fetches an internal/loopback resource it is documented to refuse. No `SECURITY.md` excludes this attack class; the product's own `assertSafeUrl` guard establishes that blocking private IPs is intended behavior.
- **Scope honesty**: the same-root-cause *class* (IPv4-in-IPv6 hex normalization) variants `::ffff:0:127.0.0.1`, `::127.0.0.1`, and `64:ff9b::127.0.0.1` were tested and **do bypass the fixed guard's string check**, but they are **not a working SSRF** in this environment (`ENETUNREACH` — the kernel does not route non-mapped IPv4-in-IPv6 to IPv4). They are reported as a guard-incompleteness/defense-in-depth gap, not as a confirmed bypass. The confirmed bypass is the redirect-following path.
- **`auth_fetch` parity**: the same redirect gap affects `auth_fetch` via `browser.ts:58`→`page.goto()` (`page.goto` follows redirects/navigations). Runtime proof focused on `download_media` because it does not require the interactive capture-button click; the code-path analysis establishes `auth_fetch` is equally affected.
