+
+
+
+ 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 };
From 4c1a0058bf39a8413b8e4f10c1bc883511a6aa4a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?tonghuaroot=20=28=E7=AB=A5=E8=AF=9D=29?=