GitHub Security Advisory: GHSA-j4rj-2jr5-m439
Package: ssrfcheck (NPM)
Severity: HIGH (CVSS 8.2)
CWE: CWE-184: Incomplete List of Disallowed Inputs, CWE-918: Server-Side Request Forgery (SSRF)
Vulnerable versions: <= 1.3.0
Patched in: unpatched

Summary: ssrfcheck Vulnerable to Server-Side Request Forgery (SSRF) and Incomplete List of Disallowed Inputs

Details:
### Summary

`ssrfcheck` v1.3.0 (latest) fails to block Server-Side Request Forgery attacks when the target private IP address is encoded as an IPv4-mapped IPv6 address (e.g. `http://[::ffff:127.0.0.1]/`). The WHATWG URL parser built into Node.js silently normalizes the IPv4 notation inside the brackets to compressed hex form (`[::ffff:7f00:1]`) before the library's private-IP regex ever runs. The regex was written to match dot-notation only and therefore never matches any real input — all seven IANA private IPv4 ranges, including the AWS/GCP/Azure metadata address `169.254.169.254`, are bypassed. Any application using `isSSRFSafeURL()` to guard HTTP requests made with user-supplied URLs is fully exposed to SSRF.

---

### Details

**Vulnerable file:** `src/is-private-ip.js`

The library detects IPv6 private addresses using the `privIp6()` function. The relevant portion:

```js
// src/is-private-ip.js  (lines ~40-60 of the published source)
function privIp6 (ip) {
  return /^::$/.test(ip) ||
    /^::1$/.test(ip) ||
    /^::f{4}:([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/.test(ip) ||
    /^::f{4}:0.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/.test(ip) ||
    /^64:ff9b::([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/.test(ip) ||
    // ... more patterns, all expect dot-notation ...
}
```

The third line is the IPv4-mapped IPv6 check. It expects input in the form `::ffff:127.0.0.1` (dots). However, the IP is extracted from the URL using `url.hostname`, which goes through the WHATWG URL parser first.

**How WHATWG URL normalizes the address** (`src/parse-url.js`):

```js
const url = new URL(normalizeURLStr(input));   // WHATWG URL parser runs here
const ipcheck = trimBrackets(url.hostname);    // e.g. '::ffff:7f00:1'  ← hex, no dots
const ipVersion = isIP(ipcheck);               // returns 6
```

The WHATWG URL spec (§5.3 IPv6 serializer) converts all embedded IPv4 notation to two 16-bit hex groups during parsing:

```
127.0.0.1       → 0x7f000001 → [0x7f00, 0x0001] → serialized as 7f00:1
169.254.169.254 → 0xa9fea9fe → [0xa9fe, 0xa9fe] → serialized as a9fe:a9fe
192.168.1.1     → 0xc0a80101 → [0xc0a8, 0x0101] → serialized as c0a8:101
```

So by the time the regex `/^::f{4}:(\d+)\.(\d+)\.(\d+)\.(\d+)$/` runs, the string it receives is `::ffff:7f00:1` — no dots, no match. The regex has been dead code since Node.js adopted WHATWG URL (v10+).

**Entry point** (`src/index.js`):

```js
if (hostIsIp && (options.noIP || isLoopbackAddr(ip) || isPrivateIP(ip, ipVersion))) {
  return false;   // ← never reached for IPv4-mapped IPv6
}
return true;      // ← always reached → BYPASS
```

---

### PoC

**Environment:** Node.js >= 10, ssrfcheck any version including v1.3.0 (latest). No configuration required — default options are vulnerable.

**Setup:**

```bash
mkdir ssrfcheck-poc && cd ssrfcheck-poc
npm init -y
npm install ssrfcheck
```

**Step 1 — confirm WHATWG URL normalization:**

```bash
node << 'EOF'
const addrs = [
  ['127.0