# Patch Analysis — CVE-2026-49857 (auth-fetch-mcp SSRF guard)

## Fix under analysis

- **Repository**: https://github.com/ymw0407/auth-fetch-mcp
- **Fix commit**: `177ec5f8ee9c2d5749035777e562f699971b0da9`
  - *Message*: "fix: block IPv4-mapped IPv6 loopback bypass in SSRF guard (GHSA-pvrj-8cg3-j5f8)"
- **Released in**: `v3.0.2` (merge commit `d4dedaf55c1d39228dbed58807ea1f9fac1328e1`, tag `v3.0.2`)
- **Also present on**: `origin/main` (`a4b92452dc9332fb4063225f3d8842f3602a54a3`, latest default branch; `src/security.ts` byte-identical to v3.0.2)
- **Single file changed**: `src/security.ts` (function `isPrivateV6`)

## What the fix changes

In `src/security.ts`, `isPrivateV6()` already special-cased `::ffff:`-prefixed addresses by extracting the suffix and calling `net.isIPv4(v4)`. The problem: the Node WHATWG URL parser hex-normalizes `::ffff:127.0.0.1` to `::ffff:7f00:1`, so `net.isIPv4("7f00:1")` returns `false` and the private-range check was skipped (loopback classified as non-private → SSRF).

The fix adds a fallback that reconstructs the dotted-decimal IPv4 from the **two trailing hex groups** and runs it through `isPrivateV4()`:

```typescript
if (lower.startsWith("::ffff:")) {
  const v4 = lower.slice(7);
  if (net.isIPv4(v4)) return isPrivateV4(v4);
  // WHATWG URL parser hex-normalizes IPv4-mapped IPv6 addresses, e.g.
  // ::ffff:127.0.0.1 -> ::ffff:7f00:1. Reconstruct the IPv4 from the two
  // trailing hex groups so the private-range check still applies.
  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);
  }
}
```

This correctly blocks the original vector: `http://[::ffff:127.0.0.1]:PORT/` is now rejected ("Refusing to fetch ..."). The repro's control case confirms this — on v3.0.2, `http://127.0.0.1:18080/` and `http://[::ffff:127.0.0.1]/` are both blocked.

## Assumptions the fix makes

1. **The hex normalization only ever yields exactly two trailing hex groups for the addresses that matter.** The reconstruction is gated on `groups.length === 2`. This holds for the canonical IPv4-**mapped** form `::ffff:a.b.c.d` (normalized to `::ffff:XX:XX`), which is the only IPv4-in-IPv6 form the Linux kernel maps back to an IPv4 connection on dual-stack sockets.
2. **`assertSafeUrl()` is the sole and sufficient gate, applied once to the request URL.** The fix assumes the URL that passes the guard is the URL the HTTP client actually fetches. There is **no re-validation of redirect targets**.
3. **IP literals are the only bypass surface.** Hostnames are resolved once via `dns.lookup(host, { all: true, verbatim: true })` and each resolved address is checked. There is no handling of DNS rebinding / TOCTOU between the guard's lookup and the fetch's lookup.
4. **The fetch layer (Playwright `ctx.request.get` / `page.goto`) does not introduce new private-IP reachability.** The fix does not constrain the fetch layer's redirect behavior at all.

## What the fix does NOT cover (gaps)

### GAP 1 — Redirect targets are never re-validated (the confirmed bypass)

`assertSafeUrl()` is called in exactly two places, both on the **initial** URL only:
- `src/tools.ts:233` — `download_media`: `const safeUrl = await assertSafeUrl(url);` then `const response = await ctx.request.get(safeUrl.toString());` (line 234)
- `src/browser.ts:58` — `navigateTo()` (used by `auth_fetch`): `const safeUrl = await assertSafeUrl(url);` then `await page.goto(safeUrl.toString(), {...})` (line 66)

Playwright's `APIRequestContext.get()` follows HTTP 3xx redirects by default, and `page.goto()` follows navigations/redirects. Neither re-runs `assertSafeUrl()` on the `Location` target. Therefore an attacker supplies a **public** URL that 302-redirects to a private/loopback URL; the guard passes the public host, and the redirect is followed to the private target with no re-validation → SSRF.

This is **confirmed end-to-end on the fixed v3.0.2 server and on latest main** (see `rca_report.md` and `bundle/logs/vuln_variant/`). The fix commit does not touch this path at all.

### GAP 2 — Non-mapped IPv4-in-IPv6 forms bypass the guard's string check (defense-in-depth gap, not a working SSRF here)

The `groups.length === 2` gate is too narrow. Probe (`bundle/vuln_variant/probe_guard.js`, log `bundle/logs/vuln_variant/probe_guard.log`) shows these addresses normalize to forms that `isPrivateV6()` returns `false` for on **both** the vulnerable and fixed guard:

| Input | Normalized host | `isPrivateV6` (fixed) | Routes to loopback? |
|---|---|---|---|
| `http://[::ffff:0:127.0.0.1]/` | `::ffff:0:7f00:1` (3 trailing groups) | `false` (bypass) | No — `ENETUNREACH` |
| `http://[::127.0.0.1]/` | `::7f00:1` (no `::ffff:` prefix) | `false` (bypass) | No — `ENETUNREACH` |
| `http://[64:ff9b::127.0.0.1]/` | `64:ff9b::7f00:1` (NAT64) | `false` (bypass) | No — `ENETUNREACH` |
| `http://[::ffff:0:169.254.169.254]/` | `::ffff:0:a9fe:a9fe` | `false` (bypass) | No — `ENETUNREACH` |

The empirical routing test (`bundle/vuln_variant/probe_routing.js`, log `bundle/logs/vuln_variant/routing_test.log`) confirms the Linux kernel in this environment does **not** route the IPv4-translated (`::ffff:0:x:y`), IPv4-compatible (`::x:y`), or NAT64 (`64:ff9b::x:y`) forms to IPv4 loopback — only the true IPv4-mapped `::ffff:x:y` (which the fix blocks) and `::1`/`127.0.0.1` (which the guard blocks) reach loopback. So these are **guard-incompleteness, not exploitable SSRF in this environment**. They remain a latent gap: in an environment with NAT64/SIIT translation or a route for `::ffff:0:0/96`, the translated form `::ffff:0:127.0.0.1` would both bypass the guard *and* route to loopback. The fix should reject all IPv4-in-IPv6 embeddings (mapped, translated, compatible, NAT64), not only the 2-group mapped form.

### GAP 3 — DNS rebinding / TOCTOU (separate root cause, out of scope for this fix)

`assertSafeUrl()` resolves the hostname once, checks the addresses, then returns. The subsequent `ctx.request.get()`/`page.goto()` performs its own resolution. A hostname that resolves to a public IP at guard time and to a private IP at fetch time (DNS rebinding) bypasses the guard. This is a distinct SSRF class from the hex-normalization bug and is not addressed by 177ec5f. Not exercised at runtime in this run; noted for completeness.

### GAP 4 — IPv6 link-local coverage is partial

`isPrivateV6()` only checks the `fe80:` prefix, but link-local is `fe80::/10` (i.e. `fe80::`–`fef0::`). Addresses like `fea0::1` are link-local but not caught. Not routed to IPv4 loopback, so lower severity, but the prefix check is incomplete.

## Behavior before vs after the fix

| Input URL | v3.0.1 (vulnerable) | v3.0.2 (fixed) | Notes |
|---|---|---|---|
| `http://[::ffff:127.0.0.1]:PORT/` | **SSRF** (guard bypass) | Blocked | Original CVE vector; fix closes it |
| `http://127.0.0.1:PORT/` | Blocked | Blocked | `isPrivateV4` already handled this |
| `http://[::ffff:0:127.0.0.1]/` | Guard bypass (no SSRF — ENETUNREACH) | Guard bypass (no SSRF — ENETUNREACH) | Fix's `length===2` too narrow; not routed |
| `http://<public>/302→http://127.0.0.1:PORT/` | **SSRF** (redirect) | **SSRF** (redirect) | **BYPASS — not covered by fix** |

## Threat model / security policy

There is **no `SECURITY.md`** and no documented threat model in the repository. The product is an MCP server that fetches attacker-influenced URLs (via MCP client / prompt injection) using a real browser, and explicitly implements an SSRF guard (`assertSafeUrl`) with private-IP blocking and an opt-in `AUTH_FETCH_ALLOW_PRIVATE`. The existence of this guard establishes the product's own security boundary: requests to private/loopback/link-local IPs are intended to be refused by default. The redirect bypass defeats that intended boundary, so it is within the product's own in-scope protection (not a documented limitation).

## Completeness assessment

The fix is **correct but incomplete**. It closes the specific hex-normalization vector it targets, but it does not make `assertSafeUrl()` the comprehensive gate the product's threat model requires: **redirect targets are never re-validated**, which yields a working SSRF on the fixed version (GAP 1, confirmed). A complete fix must re-validate every URL the fetch layer actually requests — i.e. disable redirects at the guard boundary, or wrap the fetch in a redirect-aware interceptor that calls `assertSafeUrl()` on each `Location`, or pin to the resolved IP and reject any redirect whose target resolves to a private address.
