mirror of
https://github.com/github/codeql.git
synced 2026-06-10 23:41:09 +02:00
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)
This commit is contained in:
@@ -63,6 +63,7 @@ ql/javascript/ql/src/experimental/Security/CWE-347/decodeJwtWithoutVerificationL
|
||||
ql/javascript/ql/src/experimental/Security/CWE-444/InsecureHttpParser.ql
|
||||
ql/javascript/ql/src/experimental/Security/CWE-522-DecompressionBombs/DecompressionBombs.ql
|
||||
ql/javascript/ql/src/experimental/Security/CWE-918/SSRF.ql
|
||||
ql/javascript/ql/src/experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql
|
||||
ql/javascript/ql/src/experimental/StandardLibrary/MultipleArgumentsToSetConstructor.ql
|
||||
ql/javascript/ql/src/experimental/heuristics/ql/src/Security/CWE-020/UntrustedDataToExternalAPI.ql
|
||||
ql/javascript/ql/src/experimental/heuristics/ql/src/Security/CWE-078/CommandInjection.ql
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE qhelp PUBLIC
|
||||
"-//Semmle//qhelp//EN"
|
||||
"qhelp.dtd">
|
||||
<qhelp>
|
||||
|
||||
<overview>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
The affected forms include IPv4-mapped IPv6 (<code>::ffff:169.254.169.254</code>),
|
||||
NAT64 (<code>64:ff9b::a9fe:a9fe</code>) and 6to4 (<code>2002::</code>). A URL such as
|
||||
<code>http://[::ffff:169.254.169.254]/</code> passes a dotted-quad denylist unchanged
|
||||
while still reaching the internal address.
|
||||
</p>
|
||||
</overview>
|
||||
|
||||
<recommendation>
|
||||
<p>
|
||||
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
|
||||
<code>ipaddr.js</code> classify these forms correctly via their range API, and
|
||||
SSRF-protection libraries such as <code>request-filtering-agent</code> apply the check
|
||||
after DNS resolution. Validate the resolved address rather than the textual host.
|
||||
</p>
|
||||
</recommendation>
|
||||
|
||||
<example>
|
||||
<p>
|
||||
The following guard rejects private IPv4 ranges using the <code>private-ip</code>
|
||||
package, which inspects the textual IPv4 form only. An attacker supplies
|
||||
<code>::ffff:169.254.169.254</code>, which the guard classifies as public, but the
|
||||
request still reaches the internal metadata endpoint.
|
||||
</p>
|
||||
|
||||
<sample src="examples/SsrfIpv6TransitionIncompleteGuardBad.js"/>
|
||||
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<sample src="examples/SsrfIpv6TransitionIncompleteGuardGood.js"/>
|
||||
</example>
|
||||
|
||||
<references>
|
||||
|
||||
<li>OWASP: <a href="https://owasp.org/www-community/attacks/Server_Side_Request_Forgery">Server-Side Request Forgery</a>.</li>
|
||||
<li>Common Weakness Enumeration: <a href="https://cwe.mitre.org/data/definitions/918.html">CWE-918</a>.</li>
|
||||
<li>Common Weakness Enumeration: <a href="https://cwe.mitre.org/data/definitions/1389.html">CWE-1389</a>.</li>
|
||||
|
||||
</references>
|
||||
</qhelp>
|
||||
@@ -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."
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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. |
|
||||
@@ -0,0 +1 @@
|
||||
experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user