diff --git a/javascript/ql/src/change-notes/2026-06-06-ssrf-ipv6-transition-incomplete-guard.md b/javascript/ql/src/change-notes/2026-06-06-ssrf-ipv6-transition-incomplete-guard.md new file mode 100644 index 000000000000..35bd19acf46c --- /dev/null +++ b/javascript/ql/src/change-notes/2026-06-06-ssrf-ipv6-transition-incomplete-guard.md @@ -0,0 +1,4 @@ +--- +category: newQuery +--- +* Added a new experimental query, `javascript/ssrf-ipv6-transition-incomplete-guard`, to detect SSRF host-validation guards that reject private IPv4 ranges but fail to unwrap IPv6-transition forms (IPv4-mapped `::ffff:`, NAT64 `64:ff9b::`, 6to4 `2002::`), allowing the guard to be bypassed by wrapping an internal IPv4 address in a transition literal. diff --git a/javascript/ql/src/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard.qhelp b/javascript/ql/src/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard.qhelp new file mode 100644 index 000000000000..79230285f516 --- /dev/null +++ b/javascript/ql/src/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard.qhelp @@ -0,0 +1,59 @@ + + + + +

+ Server-side request forgery (SSRF) guards frequently reject requests to internal + addresses by checking the request host against a denylist of private, loopback and + cloud-metadata IPv4 ranges. When such a guard inspects only the dotted-quad IPv4 form + and never unwraps IPv6-transition representations, it can be bypassed: the host + validator classifies the address as public, but the operating system routes the + connection to the embedded internal IPv4 endpoint. +

+

+ The affected forms include IPv4-mapped IPv6 (::ffff:169.254.169.254), + NAT64 (64:ff9b::a9fe:a9fe) and 6to4 (2002::). A URL such as + http://[::ffff:169.254.169.254]/ passes a dotted-quad denylist unchanged + while still reaching the internal address. +

+
+ + +

+ Normalize the host before validating it: parse the address with a transition-aware + library and unwrap IPv4-mapped, NAT64 and 6to4 forms to their embedded IPv4 address, + then apply the private-range check to the normalized value. Libraries such as + ipaddr.js classify these forms correctly via their range API, and + SSRF-protection libraries such as request-filtering-agent apply the check + after DNS resolution. Validate the resolved address rather than the textual host. +

+
+ + +

+ The following guard rejects private IPv4 ranges using the private-ip + package, which inspects the textual IPv4 form only. An attacker supplies + ::ffff:169.254.169.254, which the guard classifies as public, but the + request still reaches the internal metadata endpoint. +

+ + + +

+ The following guard parses the host with a transition-aware classifier, so the + embedded internal IPv4 address is detected regardless of the transition form used. +

+ + +
+ + + +
  • OWASP: Server-Side Request Forgery.
  • +
  • Common Weakness Enumeration: CWE-918.
  • +
  • Common Weakness Enumeration: CWE-1389.
  • + +
    +
    diff --git a/javascript/ql/src/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql b/javascript/ql/src/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql new file mode 100644 index 000000000000..14e0766d796b --- /dev/null +++ b/javascript/ql/src/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql @@ -0,0 +1,129 @@ +/** + * @name SSRF host guard does not reject IPv6-transition forms + * @description An SSRF host guard that rejects private or loopback IPv4 ranges but never + * unwraps IPv6-transition forms (IPv4-mapped `::ffff:`, NAT64 `64:ff9b::`, + * 6to4 `2002::`) can be bypassed by wrapping an internal IPv4 address in a + * transition literal, allowing requests to reach internal endpoints. + * @kind problem + * @problem.severity warning + * @id javascript/ssrf-ipv6-transition-incomplete-guard + * @tags security + * experimental + * external/cwe/cwe-918 + * external/cwe/cwe-1389 + */ + +import javascript + +/** + * Holds if `f` imports a dotted-quad-oriented private-IP guard package whose + * classification is performed on the textual IPv4 form and therefore returns + * `false` for an internal address wrapped in an IPv6-transition literal. + */ +predicate importsHandRolledIpGuard(File f) { + exists(DataFlow::SourceNode mod | + mod.getFile() = f and + mod = DataFlow::moduleImport(["private-ip", "is-ip", "ip", "ip-range-check"]) + ) +} + +/** + * Holds if `f` contains a call to an `isPrivate`-style host classifier, the + * common name for a hand-rolled SSRF guard. + */ +predicate hasIsPrivateCall(File f) { + exists(DataFlow::CallNode c | + c.getFile() = f and + c.getCalleeName().regexpMatch("(?i)^is_?private(ip|address|host)?$") + ) + or + exists(DataFlow::MethodCallNode m | + m.getFile() = f and + m.getMethodName().regexpMatch("(?i)^is_?private(ip|address|host)?$") + ) +} + +/** + * Holds if `f` contains a hand-written RFC 1918, loopback or cloud-metadata IPv4 + * literal used as a denylist entry. + */ +predicate hasRfc1918Literal(File f) { + exists(StringLiteral s | + s.getFile() = f and + s.getValue() + .regexpMatch("(?i).*(127\\.0\\.0\\.1|169\\.254\\.169\\.254|10\\.|192\\.168|172\\.1[6-9]|::1|fc00|fd00|metadata\\.google).*") + ) +} + +/** Holds if `f` carries any hand-rolled, dotted-quad-oriented SSRF guard signal. */ +predicate hasUnsafeGuardSignal(File f) { + importsHandRolledIpGuard(f) or + hasIsPrivateCall(f) or + hasRfc1918Literal(f) +} + +/** Holds if `func` has a name that reads as an SSRF host or URL validator. */ +predicate isSsrfValidatorFunction(Function func) { + func.getName() + .regexpMatch("(?i).*(validate|check|guard|reject|deny|block|allow|is_?safe|sanitiz)e?_?.*(url|host|ip|address|target|endpoint|webhook|origin).*") + or + func.getName() + .regexpMatch("(?i).*(is_?)?(private|internal|loopback|reserved|external)_?(ip|address|host|url).*") + or + func.getName().regexpMatch("(?i).*(ssrf|metadata).*") +} + +/** + * Holds if `f` imports a maturity-hardened, transition-aware address classifier + * or SSRF-protection library that does unwrap IPv6-transition forms. + */ +predicate importsSafeClassifier(File f) { + exists(DataFlow::SourceNode mod | + mod.getFile() = f and + mod = + DataFlow::moduleImport([ + "ipaddr.js", "ssrf-req-filter", "request-filtering-agent", "ssrf-agent", "netmask", + "ip-cidr", "cidr-matcher", "blocked-at" + ]) + ) +} + +/** + * Holds if `f` already performs an explicit IPv6-transition unwrap or + * canonicalization, so the guard does see the embedded IPv4 address. + */ +predicate hasTransitionUnwrap(File f) { + exists(StringLiteral s | + s.getFile() = f and + ( + s.getValue().matches("%64:ff9b%") or + s.getValue().matches("%::ffff%") or + s.getValue().matches("%2002:%") or + s.getValue().matches("%2001:%") + ) + ) + or + exists(Identifier id | + id.getFile() = f and + id.getName() + .regexpMatch("(?i).*(ipv4mapped|v4mapped|mappedipv4|ipv4inipv6|embeddedipv4|unwrap.*ip|toipv4|canonicaliz|isipv4compat).*") + ) + or + exists(DataFlow::MethodCallNode m | m.getFile() = f and m.getMethodName() = ["range", "kind"]) +} + +/** Holds if `f` is treated as safe (transition-aware), suppressing the alert. */ +predicate isSafe(File f) { importsSafeClassifier(f) or hasTransitionUnwrap(f) } + +from Function guard, File f +where + guard.getFile() = f and + isSsrfValidatorFunction(guard) and + hasUnsafeGuardSignal(f) and + not isSafe(f) and + not f.getRelativePath() + .regexpMatch("(?i).*/(tests?|specs?|examples?|__tests__|e2e|node_modules)/.*") +select guard, + "This SSRF host guard rejects private IPv4 ranges but never unwraps IPv6-transition forms " + + "(IPv4-mapped '::ffff:', NAT64 '64:ff9b::', 6to4 '2002::'); an attacker can wrap an internal " + + "IPv4 address in a transition literal to bypass it and reach internal endpoints." diff --git a/javascript/ql/src/experimental/Security/CWE-918/examples/SsrfIpv6TransitionIncompleteGuardBad.js b/javascript/ql/src/experimental/Security/CWE-918/examples/SsrfIpv6TransitionIncompleteGuardBad.js new file mode 100644 index 000000000000..0f0eabe1ce1a --- /dev/null +++ b/javascript/ql/src/experimental/Security/CWE-918/examples/SsrfIpv6TransitionIncompleteGuardBad.js @@ -0,0 +1,14 @@ +const isPrivate = require('private-ip'); +const fetch = require('node-fetch'); + +// BAD: `private-ip` classifies the textual IPv4 form only, so it returns false +// for `::ffff:169.254.169.254`. The guard treats the wrapped internal address as +// public, but the request still reaches the metadata endpoint. +async function validateUrlHost(host) { + if (isPrivate(host)) { + throw new Error('blocked private host'); + } + return fetch('http://' + host + '/'); +} + +module.exports = { validateUrlHost }; diff --git a/javascript/ql/src/experimental/Security/CWE-918/examples/SsrfIpv6TransitionIncompleteGuardGood.js b/javascript/ql/src/experimental/Security/CWE-918/examples/SsrfIpv6TransitionIncompleteGuardGood.js new file mode 100644 index 000000000000..0d4a9820fd69 --- /dev/null +++ b/javascript/ql/src/experimental/Security/CWE-918/examples/SsrfIpv6TransitionIncompleteGuardGood.js @@ -0,0 +1,16 @@ +const ipaddr = require('ipaddr.js'); +const fetch = require('node-fetch'); + +// GOOD: ipaddr.js parses the host and classifies it with `.range()`, which is +// transition-aware. `::ffff:169.254.169.254` parses as an IPv4-mapped address and +// is reported in the `linkLocal` range, so the guard is complete. +async function validateTargetHost(host) { + const addr = ipaddr.parse(host); + const range = addr.range(); + if (range === 'private' || range === 'loopback' || range === 'linkLocal') { + throw new Error('blocked internal host'); + } + return fetch('http://' + host + '/'); +} + +module.exports = { validateTargetHost }; diff --git a/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.expected b/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.expected new file mode 100644 index 000000000000..e488048f9afd --- /dev/null +++ b/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.expected @@ -0,0 +1,2 @@ +| bad-private-ip-pkg.js:6:1:11:1 | async f ... '/');\\n} | This SSRF host guard rejects private IPv4 ranges but never unwraps IPv6-transition forms (IPv4-mapped '::ffff:', NAT64 '64:ff9b::', 6to4 '2002::'); an attacker can wrap an internal IPv4 address in a transition literal to bypass it and reach internal endpoints. | +| bad-rfc1918-regex.js:5:1:16:1 | functio ... '/');\\n} | This SSRF host guard rejects private IPv4 ranges but never unwraps IPv6-transition forms (IPv4-mapped '::ffff:', NAT64 '64:ff9b::', 6to4 '2002::'); an attacker can wrap an internal IPv4 address in a transition literal to bypass it and reach internal endpoints. | diff --git a/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.qlref b/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.qlref new file mode 100644 index 000000000000..50159ab72fe1 --- /dev/null +++ b/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/SsrfIpv6TransitionIncompleteGuard.qlref @@ -0,0 +1 @@ +experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql \ No newline at end of file diff --git a/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/bad-private-ip-pkg.js b/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/bad-private-ip-pkg.js new file mode 100644 index 000000000000..972d7aad9b73 --- /dev/null +++ b/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/bad-private-ip-pkg.js @@ -0,0 +1,13 @@ +const isPrivate = require('private-ip'); +const fetch = require('node-fetch'); + +// BAD: `private-ip` classifies the textual IPv4 form only. It returns false for +// `::ffff:169.254.169.254`, so a transition-wrapped internal address slips past. +async function validateUrlHost(host) { // NOT OK + if (isPrivate(host)) { + throw new Error('blocked private host'); + } + return fetch('http://' + host + '/'); +} + +module.exports = { validateUrlHost }; diff --git a/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/bad-rfc1918-regex.js b/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/bad-rfc1918-regex.js new file mode 100644 index 000000000000..be70a4a5e5dc --- /dev/null +++ b/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/bad-rfc1918-regex.js @@ -0,0 +1,18 @@ +const http = require('http'); + +// BAD: a hand-written RFC 1918 / loopback / metadata denylist matched against the +// host string. The embedded IPv4 inside `::ffff:10.0.0.1` is never seen. +function checkTargetHost(host) { // NOT OK + if ( + host === '127.0.0.1' || + host === '169.254.169.254' || + host.startsWith('10.') || + host.startsWith('192.168') || + host.startsWith('172.16') + ) { + throw new Error('blocked internal host'); + } + return http.get('http://' + host + '/'); +} + +module.exports = { checkTargetHost }; diff --git a/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/good-explicit-unwrap.js b/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/good-explicit-unwrap.js new file mode 100644 index 000000000000..d7bc07079149 --- /dev/null +++ b/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/good-explicit-unwrap.js @@ -0,0 +1,32 @@ +const http = require('http'); + +const IPV4_MAPPED_PREFIX = '::ffff:'; + +// OK: this guard uses a hand-rolled denylist, but it first unwraps the +// IPv6-transition form, so the embedded IPv4 is normalized before the check. +function unwrapMapped(host) { + // strip an IPv4-mapped `::ffff:` prefix down to the embedded dotted quad + if (host.toLowerCase().startsWith(IPV4_MAPPED_PREFIX)) { + return host.slice(IPV4_MAPPED_PREFIX.length); + } + return host; +} + +function isPrivateAddress(host) { // OK + const h = unwrapMapped(host); + return ( + h === '127.0.0.1' || + h === '169.254.169.254' || + h.startsWith('10.') || + h.startsWith('192.168') + ); +} + +function validateHost(host) { // OK + if (isPrivateAddress(host)) { + throw new Error('blocked internal host'); + } + return http.get('http://' + host + '/'); +} + +module.exports = { validateHost }; diff --git a/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/good-ipaddr.js b/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/good-ipaddr.js new file mode 100644 index 000000000000..9994eba44c36 --- /dev/null +++ b/javascript/ql/test/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard/good-ipaddr.js @@ -0,0 +1,16 @@ +const ipaddr = require('ipaddr.js'); +const fetch = require('node-fetch'); + +// OK: ipaddr.js parses the address and classifies it with `.range()`, which is +// transition-aware. `::ffff:10.0.0.1` parses as an IPv4-mapped address and is +// reported in the `private` range, so the guard is complete. +async function validateTargetHost(host) { // OK + const addr = ipaddr.parse(host); + const range = addr.range(); + if (range === 'private' || range === 'loopback' || range === 'linkLocal') { + throw new Error('blocked internal host'); + } + return fetch('http://' + host + '/'); +} + +module.exports = { validateTargetHost };