mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
fix(security): block IPv6 transition addresses (NAT64/6to4/Teredo) in SSRF guard
An attacker-controlled DNS record pointing at a NAT64 (64:ff9b::/96), 6to4 (2002::/16), or Teredo (2001:0000::/32) address that embeds a private IPv4 (e.g. 64:ff9b::a9fe:a9fe = 169.254.169.254) bypassed the SSRF guard: none of the guard functions recognised these ranges, so on a host that routes the transition prefix the connection reached the embedded private target (cloud metadata / internal SSRF). Add a shared embeddedTransitionIpv4() detector and re-apply each guard's own blocklist to the extracted IPv4 in isAlwaysBlocked, isPrivateNetwork, isLinkLocal (ssrfGuard.ts) and isBlockedIp (egress-policy.ts). A transition address to a public IPv4 stays allowed so legitimate IPv6-only egress is unaffected. egress-policy.ts keeps the detector inline to preserve its dependency-free contract for the isolated plugin subprocess.
This commit is contained in:
@@ -40,10 +40,33 @@ export function isBlockedIp(ip: string): boolean {
|
||||
}
|
||||
if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
|
||||
if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA
|
||||
// IPv6 transition addresses (NAT64/6to4/Teredo) embedding an IPv4 → re-check
|
||||
// the embedded target, so a NAT64 spelling of 169.254.169.254 can't tunnel past.
|
||||
const embedded = embeddedTransitionIpv4(g);
|
||||
if (embedded) return isBlockedIp(embedded);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the expanded hextets `g` are an IPv6 transition address that embeds an IPv4
|
||||
* target, return that IPv4 in dotted form; otherwise null. Recognises the NAT64
|
||||
* well-known prefix `64:ff9b::/96`, 6to4 `2002::/16`, and Teredo `2001:0000::/32`.
|
||||
* Kept local so this module stays dependency-free for the isolated plugin child.
|
||||
*/
|
||||
function embeddedTransitionIpv4(g: number[]): string | null {
|
||||
const v4 = (hi: number, lo: number) => `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
||||
// NAT64 well-known 64:ff9b::/96 — first 96 bits fixed, last 32 = IPv4.
|
||||
if (g[0] === 0x0064 && g[1] === 0xff9b && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) {
|
||||
return v4(g[6], g[7]);
|
||||
}
|
||||
// 6to4 2002::/16 — hextets 1,2 = IPv4.
|
||||
if (g[0] === 0x2002) return v4(g[1], g[2]);
|
||||
// Teredo 2001:0000::/32 — last 32 bits = client IPv4 XOR 0xffffffff.
|
||||
if (g[0] === 0x2001 && g[1] === 0x0000) return v4(g[6] ^ 0xffff, g[7] ^ 0xffff);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Expand an IPv6 literal into its 8 numeric hextets, or null if not a valid IPv6. */
|
||||
function expandV6(ip: string): number[] | null {
|
||||
let h = ip.toLowerCase().replace(/%.*$/, '');
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* IPv6 helpers shared by the SSRF guard (`utils/ssrfGuard.ts`) and the plugin
|
||||
* egress policy (`nest/plugins/runtime/egress-policy.ts`).
|
||||
*
|
||||
* The headline job is detecting IPv6 *transition* addresses that embed an IPv4
|
||||
* target. A hostname that resolves to e.g. `64:ff9b::a9fe:a9fe` (NAT64 spelling
|
||||
* of 169.254.169.254) would otherwise sail past every IPv6 prefix check and,
|
||||
* on a host with a NAT64/6to4 gateway, connect straight through to the embedded
|
||||
* private IPv4 — an SSRF-guard bypass. Callers extract the embedded IPv4 and
|
||||
* re-apply their own blocklist to it.
|
||||
*/
|
||||
|
||||
/** Expand an IPv6 literal into its 8 numeric hextets, or null if not a valid IPv6. */
|
||||
export function expandIpv6(ip: string): number[] | null {
|
||||
let h = ip.toLowerCase().replace(/%.*$/, '');
|
||||
if (!h.includes(':')) return null;
|
||||
const dotted = h.match(/^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
if (dotted) {
|
||||
const v4 = dotted[2].split('.').map(Number);
|
||||
if (v4.some((n) => n > 255)) return null;
|
||||
h = dotted[1] + ((v4[0] << 8) | v4[1]).toString(16) + ':' + ((v4[2] << 8) | v4[3]).toString(16);
|
||||
}
|
||||
const halves = h.split('::');
|
||||
if (halves.length > 2) return null;
|
||||
const head = halves[0] ? halves[0].split(':') : [];
|
||||
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
||||
const groups =
|
||||
halves.length === 2 ? [...head, ...Array(8 - head.length - tail.length).fill('0'), ...tail] : head;
|
||||
if (groups.length !== 8) return null;
|
||||
const nums = groups.map((x) => (x === '' ? NaN : parseInt(x, 16)));
|
||||
return nums.some((n) => !Number.isInteger(n) || n < 0 || n > 0xffff) ? null : nums;
|
||||
}
|
||||
|
||||
/**
|
||||
* If `ip` is an IPv6 transition address that embeds an IPv4 target, return the
|
||||
* embedded IPv4 in dotted form; otherwise null. Recognised ranges:
|
||||
*
|
||||
* - NAT64 well-known prefix `64:ff9b::/96` — last 32 bits are the IPv4
|
||||
* - 6to4 `2002::/16` — hextets 1–2 are the IPv4
|
||||
* - Teredo `2001:0000::/32` — last 32 bits are the client IPv4, XOR 0xffffffff
|
||||
*
|
||||
* Only the *well-known* NAT64 prefix is detected; custom-prefix NAT64 (RFC 6052
|
||||
* /32–/64, RFC 8215 `64:ff9b:1::/48`) can't be recognised without site config.
|
||||
*/
|
||||
export function embeddedTransitionIpv4(ip: string): string | null {
|
||||
const g = expandIpv6(ip.replace(/^\[/, '').replace(/\]$/, ''));
|
||||
if (!g) return null;
|
||||
const v4 = (hi: number, lo: number) => `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
||||
// NAT64 well-known 64:ff9b::/96 — first 96 bits fixed, last 32 = IPv4.
|
||||
if (g[0] === 0x0064 && g[1] === 0xff9b && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) {
|
||||
return v4(g[6], g[7]);
|
||||
}
|
||||
// 6to4 2002::/16 — hextets 1,2 = IPv4.
|
||||
if (g[0] === 0x2002) return v4(g[1], g[2]);
|
||||
// Teredo 2001:0000::/32 — last 32 bits = client IPv4 XOR 0xffffffff.
|
||||
if (g[0] === 0x2001 && g[1] === 0x0000) return v4(g[6] ^ 0xffff, g[7] ^ 0xffff);
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import dns from 'node:dns/promises';
|
||||
import { Agent } from 'undici';
|
||||
import { embeddedTransitionIpv4 } from './ipv6';
|
||||
|
||||
const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK?.toLowerCase() === 'true';
|
||||
|
||||
@@ -23,6 +24,9 @@ function isAlwaysBlocked(ip: string): boolean {
|
||||
if (addr.startsWith('169.254.') || /^fe80:/i.test(addr)) return true;
|
||||
// IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x
|
||||
if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true;
|
||||
// IPv6 transition addresses (NAT64/6to4/Teredo) embedding a hard-blocked IPv4.
|
||||
const embedded = embeddedTransitionIpv4(addr);
|
||||
if (embedded) return isAlwaysBlocked(embedded);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -43,6 +47,9 @@ function isPrivateNetwork(ip: string): boolean {
|
||||
if (/^::ffff:10\./i.test(addr)) return true;
|
||||
if (/^::ffff:172\.(1[6-9]|2\d|3[01])\./i.test(addr)) return true;
|
||||
if (/^::ffff:192\.168\./i.test(addr)) return true;
|
||||
// IPv6 transition addresses (NAT64/6to4/Teredo) embedding a private IPv4.
|
||||
const embedded = embeddedTransitionIpv4(addr);
|
||||
if (embedded) return isPrivateNetwork(embedded);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -117,6 +124,11 @@ function isLinkLocal(ip: string): boolean {
|
||||
// (100.64.0.0/10), which safeFetchLlm otherwise allows for a LAN model server —
|
||||
// so block the specific metadata IPs directly rather than the whole range.
|
||||
if (addr === '100.100.100.200' || addr === '100.100.100.100') return true;
|
||||
// NAT64/6to4/Teredo embedding one of the above link-local/metadata IPs — extract
|
||||
// the embedded IPv4 and re-check. A transition address to a public or LAN IPv4
|
||||
// stays allowed (safeFetchLlm deliberately permits private/loopback targets).
|
||||
const embedded = embeddedTransitionIpv4(addr);
|
||||
if (embedded) return isLinkLocal(embedded);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user