From c26a8e06f816dbb92b154bb423ece2a1b489214d Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 18 Jul 2026 17:16:47 +0200 Subject: [PATCH] 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. --- .../src/nest/plugins/runtime/egress-policy.ts | 23 ++++++++ server/src/utils/ipv6.ts | 58 +++++++++++++++++++ server/src/utils/ssrfGuard.ts | 12 ++++ .../tests/unit/plugins/egress-policy.test.ts | 6 ++ server/tests/unit/utils/ipv6.test.ts | 54 +++++++++++++++++ server/tests/unit/utils/ssrfGuard.test.ts | 56 ++++++++++++++++++ 6 files changed, 209 insertions(+) create mode 100644 server/src/utils/ipv6.ts create mode 100644 server/tests/unit/utils/ipv6.test.ts diff --git a/server/src/nest/plugins/runtime/egress-policy.ts b/server/src/nest/plugins/runtime/egress-policy.ts index 168c7056a..36663f8a5 100644 --- a/server/src/nest/plugins/runtime/egress-policy.ts +++ b/server/src/nest/plugins/runtime/egress-policy.ts @@ -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(/%.*$/, ''); diff --git a/server/src/utils/ipv6.ts b/server/src/utils/ipv6.ts new file mode 100644 index 000000000..142221661 --- /dev/null +++ b/server/src/utils/ipv6.ts @@ -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; +} diff --git a/server/src/utils/ssrfGuard.ts b/server/src/utils/ssrfGuard.ts index 520fdf3d4..4614ba2e3 100644 --- a/server/src/utils/ssrfGuard.ts +++ b/server/src/utils/ssrfGuard.ts @@ -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; } diff --git a/server/tests/unit/plugins/egress-policy.test.ts b/server/tests/unit/plugins/egress-policy.test.ts index 15db1c3a1..53158adca 100644 --- a/server/tests/unit/plugins/egress-policy.test.ts +++ b/server/tests/unit/plugins/egress-policy.test.ts @@ -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); }); diff --git a/server/tests/unit/utils/ipv6.test.ts b/server/tests/unit/utils/ipv6.test.ts new file mode 100644 index 000000000..dfa9ffa60 --- /dev/null +++ b/server/tests/unit/utils/ipv6.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { expandIpv6, embeddedTransitionIpv4 } from '../../../src/utils/ipv6'; + +describe('expandIpv6', () => { + it.each([ + ['::1', [0, 0, 0, 0, 0, 0, 0, 1]], + ['2606:4700::1111', [0x2606, 0x4700, 0, 0, 0, 0, 0, 0x1111]], + ['fe80::1', [0xfe80, 0, 0, 0, 0, 0, 0, 1]], + // Dotted-quad tail (IPv4-mapped) is normalised into hextets. + ['::ffff:169.254.169.254', [0, 0, 0, 0, 0, 0xffff, 0xa9fe, 0xa9fe]], + ['64:ff9b::a9fe:a9fe', [0x0064, 0xff9b, 0, 0, 0, 0, 0xa9fe, 0xa9fe]], + ])('expands %s', (ip, expected) => { + expect(expandIpv6(ip)).toEqual(expected); + }); + + it.each(['8.8.8.8', 'not-an-ip', '', '::ffff:999.1.1.1', '1::2::3'])( + 'returns null for non-IPv6 / invalid %s', + (ip) => { + expect(expandIpv6(ip)).toBeNull(); + }, + ); +}); + +describe('embeddedTransitionIpv4', () => { + it.each([ + // NAT64 well-known 64:ff9b::/96 + ['64:ff9b::a9fe:a9fe', '169.254.169.254'], + ['64:ff9b::7f00:1', '127.0.0.1'], + ['64:ff9b::a00:1', '10.0.0.1'], + ['64:ff9b::808:808', '8.8.8.8'], + ['[64:ff9b::a9fe:a9fe]', '169.254.169.254'], // bracketed literal + // 6to4 2002::/16 + ['2002:7f00:1::', '127.0.0.1'], + ['2002:a9fe:a9fe::', '169.254.169.254'], + ['2002:808:808::', '8.8.8.8'], + // Teredo 2001:0000::/32 — client IPv4 is XOR 0xffffffff of the last 32 bits. + ['2001::5601:5601', '169.254.169.254'], + ])('extracts %s -> %s', (ip, embedded) => { + expect(embeddedTransitionIpv4(ip)).toBe(embedded); + }); + + it.each([ + '8.8.8.8', // plain IPv4 — no colon + '169.254.169.254', + '::ffff:169.254.169.254', // IPv4-mapped, handled by other checks not this one + '::a9fe:a9fe', // IPv4-compatible + '2606:4700::1111', // ordinary public IPv6 + 'fe80::1', // link-local, not a transition prefix + '64:ff9b:1::a9fe:a9fe', // NAT64 local-use /48 prefix — deliberately NOT matched + 'garbage', + ])('returns null for non-transition %s', (ip) => { + expect(embeddedTransitionIpv4(ip)).toBeNull(); + }); +}); diff --git a/server/tests/unit/utils/ssrfGuard.test.ts b/server/tests/unit/utils/ssrfGuard.test.ts index f7a6e3182..af572ee1f 100644 --- a/server/tests/unit/utils/ssrfGuard.test.ts +++ b/server/tests/unit/utils/ssrfGuard.test.ts @@ -145,6 +145,40 @@ describe('checkSsrf', () => { }); }); + // SEC-005 — IPv6 transition addresses (NAT64/6to4/Teredo) must not tunnel past + // the guard by embedding a blocked IPv4 target. + describe('IPv6 transition addresses (NAT64/6to4/Teredo)', () => { + it.each([ + ['NAT64 → metadata', '64:ff9b::a9fe:a9fe'], // 169.254.169.254 + ['NAT64 → loopback', '64:ff9b::7f00:1'], // 127.0.0.1 + ['6to4 → metadata', '2002:a9fe:a9fe::'], + ['6to4 → loopback', '2002:7f00:1::'], + ['Teredo → metadata', '2001::5601:5601'], + ])('blocks %s (%s)', async (_label, ip) => { + mockIp(ip); + const result = await checkSsrf('http://attacker.example'); + expect(result.allowed).toBe(false); + expect(result.isPrivate).toBe(true); + }); + + it('blocks a NAT64 address embedding an RFC-1918 target (10.0.0.1)', async () => { + mockIp('64:ff9b::a00:1'); + const result = await checkSsrf('http://attacker.example'); + expect(result.allowed).toBe(false); + expect(result.isPrivate).toBe(true); + }); + + it.each([ + ['NAT64 → public 8.8.8.8', '64:ff9b::808:808'], + ['6to4 → public 8.8.8.8', '2002:808:808::'], + ])('still allows %s — legitimate IPv6→IPv4 egress', async (_label, ip) => { + mockIp(ip); + const result = await checkSsrf('http://cdn.example'); + expect(result.allowed).toBe(true); + expect(result.isPrivate).toBe(false); + }); + }); + describe('internal hostname suffixes', () => { it('blocks .local domains', async () => { const result = await checkSsrf('http://myserver.local'); @@ -490,6 +524,28 @@ describe('safeFetchLlm', () => { await expect(safeFetchLlm('http://model.example/chat')).rejects.toThrow(SsrfBlockedError); }); + it.each([ + ['NAT64 → metadata', '64:ff9b::a9fe:a9fe'], + ['6to4 → metadata', '2002:a9fe:a9fe::'], + ['Teredo → metadata', '2001::5601:5601'], + ])('blocks an IPv6 transition address to the metadata IP: %s', async (_label, ip) => { + mockIp(ip); + const mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + await expect(safeFetchLlm('http://ollama.evil.example/chat')).rejects.toThrow(SsrfBlockedError); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('still allows a NAT64 address to a LAN model server (192.168.1.50)', async () => { + // The LLM guard permits private/LAN targets — only link-local/metadata is blocked, + // so a NAT64 spelling of a LAN model host must keep working. + mockIp('64:ff9b::c0a8:132'); // 192.168.1.50 + const mockFetch = vi.fn().mockResolvedValue({ ok: true } as Response); + vi.stubGlobal('fetch', mockFetch); + await safeFetchLlm('http://lan-model.example/chat'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + it('stops following after the redirect cap', async () => { mockIp('203.0.113.10'); const mockFetch = vi