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:
jubnl
2026-07-18 17:16:47 +02:00
parent 3e61579347
commit c26a8e06f8
6 changed files with 209 additions and 0 deletions
@@ -18,6 +18,10 @@ describe('isBlockedIp', () => {
'::1', '::', 'fe80::1', 'fc00::1', 'fd12:3456::1', '::ffff:127.0.0.1', '::ffff:10.0.0.1',
// non-canonical IPv6 spellings must be blocked too (canonicalization)
'0::1', '::ffff:a9fe:a9fe', '::ffff:169.254.169.254', '0:0:0:0:0:0:0:1', 'fD00::1', '::ffff:7f00:1',
// IPv6 transition addresses (NAT64/6to4/Teredo) embedding a blocked IPv4
'64:ff9b::a9fe:a9fe', '64:ff9b::7f00:1', '64:ff9b::a00:1', // NAT64 → metadata / loopback / 10.x
'2002:a9fe:a9fe::', '2002:7f00:1::', // 6to4 → metadata / loopback
'2001::5601:5601', // Teredo → 169.254.169.254
])('blocks %s', (ip) => {
expect(isBlockedIp(ip)).toBe(true);
});
@@ -25,6 +29,8 @@ describe('isBlockedIp', () => {
it.each([
'8.8.8.8', '1.1.1.1', '140.82.121.3', '172.15.0.1', '172.32.0.1',
'100.63.0.1', '100.128.0.1', '2606:4700::1111', '::ffff:8.8.8.8',
// transition addresses to a public IPv4 are legitimate egress, not blocked
'64:ff9b::808:808', '2002:808:808::',
])('allows public %s', (ip) => {
expect(isBlockedIp(ip)).toBe(false);
});