Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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."
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
@@ -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. |
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
experimental/Security/CWE-918/SsrfIpv6TransitionIncompleteGuard.ql
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
@@ -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 };
Loading