Skip to content

Commit b60bf8c

Browse files
authored
Merge pull request #21950 from tonghuaroot/experimental-ssrf-ipv6-transition-js
Add experimental query: SSRF host guard missing IPv6-transition unwrap (CWE-918/CWE-1389)
2 parents f591987 + 4c1a005 commit b60bf8c

12 files changed

Lines changed: 305 additions & 0 deletions

javascript/ql/integration-tests/query-suite/not_included_in_qls.expected

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ ql/javascript/ql/src/experimental/Security/CWE-347/decodeJwtWithoutVerificationL
6363
ql/javascript/ql/src/experimental/Security/CWE-444/InsecureHttpParser.ql
6464
ql/javascript/ql/src/experimental/Security/CWE-522-DecompressionBombs/DecompressionBombs.ql
6565
ql/javascript/ql/src/experimental/Security/CWE-918/SSRF.ql
66+
ql/javascript/ql/src/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql
6667
ql/javascript/ql/src/experimental/StandardLibrary/MultipleArgumentsToSetConstructor.ql
6768
ql/javascript/ql/src/experimental/heuristics/ql/src/Security/CWE-020/UntrustedDataToExternalAPI.ql
6869
ql/javascript/ql/src/experimental/heuristics/ql/src/Security/CWE-078/CommandInjection.ql
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
category: newQuery
3+
---
4+
* 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.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<!DOCTYPE qhelp PUBLIC
2+
"-//Semmle//qhelp//EN"
3+
"qhelp.dtd">
4+
<qhelp>
5+
6+
<overview>
7+
<p>
8+
Server-side request forgery (SSRF) guards frequently reject requests to internal
9+
addresses by checking the request host against a denylist of private, loopback and
10+
cloud-metadata IPv4 ranges. When such a guard inspects only the dotted-quad IPv4 form
11+
and never unwraps IPv6-transition representations, it can be bypassed: the host
12+
validator classifies the address as public, but the operating system routes the
13+
connection to the embedded internal IPv4 endpoint.
14+
</p>
15+
<p>
16+
The affected forms include IPv4-mapped IPv6 (<code>::ffff:169.254.169.254</code>),
17+
NAT64 (<code>64:ff9b::a9fe:a9fe</code>) and 6to4 (<code>2002::</code>). A URL such as
18+
<code>http://[::ffff:169.254.169.254]/</code> passes a dotted-quad denylist unchanged
19+
while still reaching the internal address.
20+
</p>
21+
</overview>
22+
23+
<recommendation>
24+
<p>
25+
Normalize the host before validating it: parse the address with a transition-aware
26+
library and unwrap IPv4-mapped, NAT64 and 6to4 forms to their embedded IPv4 address,
27+
then apply the private-range check to the normalized value. Libraries such as
28+
<code>ipaddr.js</code> classify these forms correctly via their range API, and
29+
SSRF-protection libraries such as <code>request-filtering-agent</code> apply the check
30+
after DNS resolution. Validate the resolved address rather than the textual host.
31+
</p>
32+
</recommendation>
33+
34+
<example>
35+
<p>
36+
The following guard rejects private IPv4 ranges using the <code>private-ip</code>
37+
package, which inspects the textual IPv4 form only. An attacker supplies
38+
<code>::ffff:169.254.169.254</code>, which the guard classifies as public, but the
39+
request still reaches the internal metadata endpoint.
40+
</p>
41+
42+
<sample src="examples/SsrfIpv6TransitionIncompleteGuardBad.js"/>
43+
44+
<p>
45+
The following guard parses the host with a transition-aware classifier, so the
46+
embedded internal IPv4 address is detected regardless of the transition form used.
47+
</p>
48+
49+
<sample src="examples/SsrfIpv6TransitionIncompleteGuardGood.js"/>
50+
</example>
51+
52+
<references>
53+
54+
<li>OWASP: <a href="https://owasp.org/www-community/attacks/Server_Side_Request_Forgery">Server-Side Request Forgery</a>.</li>
55+
<li>Common Weakness Enumeration: <a href="https://cwe.mitre.org/data/definitions/918.html">CWE-918</a>.</li>
56+
<li>Common Weakness Enumeration: <a href="https://cwe.mitre.org/data/definitions/1389.html">CWE-1389</a>.</li>
57+
58+
</references>
59+
</qhelp>
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* @name SSRF host guard does not reject IPv6-transition forms
3+
* @description An SSRF host guard that rejects private or loopback IPv4 ranges but never
4+
* unwraps IPv6-transition forms (IPv4-mapped `::ffff:`, NAT64 `64:ff9b::`,
5+
* 6to4 `2002::`) can be bypassed by wrapping an internal IPv4 address in a
6+
* transition literal, allowing requests to reach internal endpoints.
7+
* @kind problem
8+
* @problem.severity warning
9+
* @id javascript/ssrf-ipv6-transition-incomplete-guard
10+
* @tags security
11+
* experimental
12+
* external/cwe/cwe-918
13+
* external/cwe/cwe-1389
14+
*/
15+
16+
import javascript
17+
18+
/**
19+
* Holds if `f` imports a dotted-quad-oriented private-IP guard package whose
20+
* classification is performed on the textual IPv4 form and therefore returns
21+
* `false` for an internal address wrapped in an IPv6-transition literal.
22+
*/
23+
predicate importsHandRolledIpGuard(File f) {
24+
exists(DataFlow::SourceNode mod |
25+
mod.getFile() = f and
26+
mod = DataFlow::moduleImport(["private-ip", "is-ip", "ip", "ip-range-check"])
27+
)
28+
}
29+
30+
/**
31+
* Holds if `f` contains a call to an `isPrivate`-style host classifier, the
32+
* common name for a hand-rolled SSRF guard.
33+
*/
34+
predicate hasIsPrivateCall(File f) {
35+
exists(DataFlow::CallNode c |
36+
c.getFile() = f and
37+
c.getCalleeName().regexpMatch("(?i)^is_?private(ip|address|host)?$")
38+
)
39+
or
40+
exists(DataFlow::MethodCallNode m |
41+
m.getFile() = f and
42+
m.getMethodName().regexpMatch("(?i)^is_?private(ip|address|host)?$")
43+
)
44+
}
45+
46+
/**
47+
* Holds if `f` contains a hand-written RFC 1918, loopback or cloud-metadata IPv4
48+
* literal used as a denylist entry.
49+
*/
50+
predicate hasRfc1918Literal(File f) {
51+
exists(StringLiteral s |
52+
s.getFile() = f and
53+
s.getValue()
54+
.regexpMatch("(?i).*(127\\.0\\.0\\.1|169\\.254\\.169\\.254|10\\.|192\\.168|172\\.1[6-9]|::1|fc00|fd00|metadata\\.google).*")
55+
)
56+
}
57+
58+
/** Holds if `f` carries any hand-rolled, dotted-quad-oriented SSRF guard signal. */
59+
predicate hasUnsafeGuardSignal(File f) {
60+
importsHandRolledIpGuard(f) or
61+
hasIsPrivateCall(f) or
62+
hasRfc1918Literal(f)
63+
}
64+
65+
/** Holds if `func` has a name that reads as an SSRF host or URL validator. */
66+
predicate isSsrfValidatorFunction(Function func) {
67+
func.getName()
68+
.regexpMatch("(?i).*(validate|check|guard|reject|deny|block|allow|is_?safe|sanitiz)e?_?.*(url|host|ip|address|target|endpoint|webhook|origin).*")
69+
or
70+
func.getName()
71+
.regexpMatch("(?i).*(is_?)?(private|internal|loopback|reserved|external)_?(ip|address|host|url).*")
72+
or
73+
func.getName().regexpMatch("(?i).*(ssrf|metadata).*")
74+
}
75+
76+
/**
77+
* Holds if `f` imports a maturity-hardened, transition-aware address classifier
78+
* or SSRF-protection library that does unwrap IPv6-transition forms.
79+
*/
80+
predicate importsSafeClassifier(File f) {
81+
exists(DataFlow::SourceNode mod |
82+
mod.getFile() = f and
83+
mod =
84+
DataFlow::moduleImport([
85+
"ipaddr.js", "ssrf-req-filter", "request-filtering-agent", "ssrf-agent", "netmask",
86+
"ip-cidr", "cidr-matcher", "blocked-at"
87+
])
88+
)
89+
}
90+
91+
/**
92+
* Holds if `f` already performs an explicit IPv6-transition unwrap or
93+
* canonicalization, so the guard does see the embedded IPv4 address.
94+
*/
95+
predicate hasTransitionUnwrap(File f) {
96+
exists(StringLiteral s |
97+
s.getFile() = f and
98+
(
99+
s.getValue().matches("%64:ff9b%") or
100+
s.getValue().matches("%::ffff%") or
101+
s.getValue().matches("%2002:%") or
102+
s.getValue().matches("%2001:%")
103+
)
104+
)
105+
or
106+
exists(Identifier id |
107+
id.getFile() = f and
108+
id.getName()
109+
.regexpMatch("(?i).*(ipv4mapped|v4mapped|mappedipv4|ipv4inipv6|embeddedipv4|unwrap.*ip|toipv4|canonicaliz|isipv4compat).*")
110+
)
111+
or
112+
exists(DataFlow::MethodCallNode m | m.getFile() = f and m.getMethodName() = ["range", "kind"])
113+
}
114+
115+
/** Holds if `f` is treated as safe (transition-aware), suppressing the alert. */
116+
predicate isSafe(File f) { importsSafeClassifier(f) or hasTransitionUnwrap(f) }
117+
118+
from Function guard, File f
119+
where
120+
guard.getFile() = f and
121+
isSsrfValidatorFunction(guard) and
122+
hasUnsafeGuardSignal(f) and
123+
not isSafe(f) and
124+
not f.getRelativePath()
125+
.regexpMatch("(?i).*/(tests?|specs?|examples?|__tests__|e2e|node_modules)/.*")
126+
select guard,
127+
"This SSRF host guard rejects private IPv4 ranges but never unwraps IPv6-transition forms " +
128+
"(IPv4-mapped '::ffff:', NAT64 '64:ff9b::', 6to4 '2002::'); an attacker can wrap an internal " +
129+
"IPv4 address in a transition literal to bypass it and reach internal endpoints."
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
const isPrivate = require('private-ip');
2+
const fetch = require('node-fetch');
3+
4+
// BAD: `private-ip` classifies the textual IPv4 form only, so it returns false
5+
// for `::ffff:169.254.169.254`. The guard treats the wrapped internal address as
6+
// public, but the request still reaches the metadata endpoint.
7+
async function validateUrlHost(host) {
8+
if (isPrivate(host)) {
9+
throw new Error('blocked private host');
10+
}
11+
return fetch('http://' + host + '/');
12+
}
13+
14+
module.exports = { validateUrlHost };
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
const ipaddr = require('ipaddr.js');
2+
const fetch = require('node-fetch');
3+
4+
// GOOD: ipaddr.js parses the host and classifies it with `.range()`, which is
5+
// transition-aware. `::ffff:169.254.169.254` parses as an IPv4-mapped address and
6+
// is reported in the `linkLocal` range, so the guard is complete.
7+
async function validateTargetHost(host) {
8+
const addr = ipaddr.parse(host);
9+
const range = addr.range();
10+
if (range === 'private' || range === 'loopback' || range === 'linkLocal') {
11+
throw new Error('blocked internal host');
12+
}
13+
return fetch('http://' + host + '/');
14+
}
15+
16+
module.exports = { validateTargetHost };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
| 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. |
2+
| 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. |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const isPrivate = require('private-ip');
2+
const fetch = require('node-fetch');
3+
4+
// BAD: `private-ip` classifies the textual IPv4 form only. It returns false for
5+
// `::ffff:169.254.169.254`, so a transition-wrapped internal address slips past.
6+
async function validateUrlHost(host) { // NOT OK
7+
if (isPrivate(host)) {
8+
throw new Error('blocked private host');
9+
}
10+
return fetch('http://' + host + '/');
11+
}
12+
13+
module.exports = { validateUrlHost };
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const http = require('http');
2+
3+
// BAD: a hand-written RFC 1918 / loopback / metadata denylist matched against the
4+
// host string. The embedded IPv4 inside `::ffff:10.0.0.1` is never seen.
5+
function checkTargetHost(host) { // NOT OK
6+
if (
7+
host === '127.0.0.1' ||
8+
host === '169.254.169.254' ||
9+
host.startsWith('10.') ||
10+
host.startsWith('192.168') ||
11+
host.startsWith('172.16')
12+
) {
13+
throw new Error('blocked internal host');
14+
}
15+
return http.get('http://' + host + '/');
16+
}
17+
18+
module.exports = { checkTargetHost };

0 commit comments

Comments
 (0)