From 51eb50ec22017995160a10cb226015c1bf22e830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Menezes?= Date: Wed, 17 Jun 2026 17:33:29 -0300 Subject: [PATCH] fix(security): block NAT64/6to4/Teredo IPv6 addresses embedding private IPv4 in SSRF guard --- backend/src/api/preview.e2e.test.ts | 58 +++++++ backend/src/utils/url-validation.test.ts | 187 ++++++++++++++++++++++- backend/src/utils/url-validation.ts | 43 +++++- 3 files changed, 285 insertions(+), 3 deletions(-) diff --git a/backend/src/api/preview.e2e.test.ts b/backend/src/api/preview.e2e.test.ts index d813df82f..bceed770b 100644 --- a/backend/src/api/preview.e2e.test.ts +++ b/backend/src/api/preview.e2e.test.ts @@ -126,4 +126,62 @@ describe('GET /v1/preview — e2e', () => { ) expect(res.status).toBe(401) }) + + // --- SSRF advisory regression (thunderbolt_SSRF.md) --- + // The advisory claims a path-style `GET /link-preview/[target_url]` endpoint + // with no validation. That route does not exist, and the real `POST /v1/preview` + // blocks every claimed vector. These tests pin that down at the endpoint level. + + it('does not expose the advisory path-style /link-preview endpoint (404)', async () => { + handle = await createTestApp({}) + const res = await handle.app.handle( + new Request('http://localhost/link-preview/http%3A%2F%2F127.0.0.1%3A8000%2Fv1%2Fhealth', { + headers: authHeaders(handle.bearerToken), + }), + ) + expect(res.status).toBe(404) + }) + + it('rejects the advisory PoC loopback target with 400', async () => { + handle = await createTestApp({}) + const res = await handle.app.handle( + new Request(`http://localhost/v1/preview`, { + method: 'POST', + headers: { ...authHeaders(handle.bearerToken), 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'http://127.0.0.1:8000/v1/health' }), + }), + ) + expect(res.status).toBe(400) + }) + + it('rejects a decimal-encoded loopback target (http://2130706433/) with 400', async () => { + handle = await createTestApp({}) + const res = await handle.app.handle( + new Request(`http://localhost/v1/preview`, { + method: 'POST', + headers: { ...authHeaders(handle.bearerToken), 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'http://2130706433/' }), + }), + ) + expect(res.status).toBe(400) + }) + + it('blocks a hostname that resolves to a private address (DNS rebinding) without leaking', async () => { + // The default e2e resolver maps `private.test` → 192.168.1.1, so the + // hostname passes the literal pre-check but is blocked at DNS-pin time. + handle = await createTestApp({}) + const res = await handle.app.handle( + new Request(`http://localhost/v1/preview`, { + method: 'POST', + headers: { ...authHeaders(handle.bearerToken), 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'https://private.test/latest/meta-data/' }), + }), + ) + // Blocked safe-fetch surfaces as the empty fallback, never cached. + expect(res.status).toBe(200) + expect(res.headers.get('cache-control')).not.toBe('private, max-age=600') + const data = (await res.json()) as Record + expect(data.title).toBeNull() + expect(data.previewImageUrl).toBeNull() + }) }) diff --git a/backend/src/utils/url-validation.test.ts b/backend/src/utils/url-validation.test.ts index dd6abeab9..8d3a9a6b5 100644 --- a/backend/src/utils/url-validation.test.ts +++ b/backend/src/utils/url-validation.test.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { describe, expect, it } from 'bun:test' -import { isPrivateAddress, validateSafeUrl } from './url-validation' +import { createSafeFetch, isPrivateAddress, validateAndPin, validateSafeUrl, type DnsLookup } from './url-validation' describe('isPrivateAddress', () => { // --- Blocked IPv4 ranges --- @@ -66,6 +66,30 @@ describe('isPrivateAddress', () => { expect(isPrivateAddress('[fe80::1]')).toBe(true) }) + // --- IPv6 transition addresses embedding a PRIVATE IPv4 (NAT64/6to4/Teredo) --- + // A host with NAT64/DNS64, 6to4, or Teredo connectivity routes these to the + // embedded IPv4, so a private embed must be blocked. + it.each([ + ['64:ff9b::7f00:1', 'NAT64 (rfc6052) → 127.0.0.1'], + ['64:ff9b::a9fe:a9fe', 'NAT64 → 169.254.169.254 (cloud metadata)'], + ['64:ff9b::c0a8:1', 'NAT64 → 192.168.0.1'], + ['2002:7f00:1::', '6to4 → 127.0.0.1'], + ['2002:a9fe:a9fe::', '6to4 → 169.254.169.254'], + ['::ffff:0:7f00:1', 'stateless translation (rfc6145) → 127.0.0.1'], + ['2001:0:0:0:0:0:80ff:fffe', 'Teredo client (one’s-complement) → 127.0.0.1'], + ])('blocks %s (%s)', (ip) => { + expect(isPrivateAddress(ip)).toBe(true) + }) + + // The embedded-IPv4 check must NOT block transition addresses wrapping a PUBLIC + // IPv4 — on a DNS64 deployment, legitimate IPv4-only sites resolve to 64:ff9b::. + it.each([ + ['64:ff9b::808:808', 'NAT64 → 8.8.8.8'], + ['2002:0808:0808::', '6to4 → 8.8.8.8'], + ])('allows %s (%s)', (ip) => { + expect(isPrivateAddress(ip)).toBe(false) + }) + // --- Allowed addresses --- it.each([ @@ -121,4 +145,165 @@ describe('validateSafeUrl', () => { expect(validateSafeUrl('http://100.64.0.1/internal').valid).toBe(false) expect(validateSafeUrl('http://198.18.0.1/internal').valid).toBe(false) }) + + // --- SSRF advisory regression: alternate IP encodings --- + // The WHATWG URL parser canonicalises decimal/octal/hex/short IPv4 forms to + // dotted-quad before our check runs, so the loopback guard still fires. + it.each([ + ['http://2130706433/', 'decimal 127.0.0.1'], + ['http://0x7f000001/', 'hex 127.0.0.1'], + ['http://0177.0.0.1/', 'octal-prefixed 127.0.0.1'], + ['http://0x7f.1/', 'mixed hex/short 127.0.0.1'], + ['http://127.1/', 'short-form 127.0.0.1'], + ['http://127.0.0.1./', 'trailing-dot loopback'], + ['http://0/', 'bare 0 → 0.0.0.0'], + ])('blocks alternate IP encoding %s (%s)', (url) => { + expect(validateSafeUrl(url).valid).toBe(false) + }) + + // --- SSRF advisory regression: bracketed IPv6 internal literals --- + it.each([ + ['http://[::1]/', 'IPv6 loopback'], + ['http://[::]/', 'IPv6 unspecified'], + ['http://[::ffff:127.0.0.1]/', 'IPv4-mapped loopback'], + ['http://[64:ff9b::7f00:1]/', 'NAT64 wrapping loopback'], + ['http://[64:ff9b::a9fe:a9fe]/', 'NAT64 wrapping cloud metadata'], + ])('blocks bracketed IPv6 internal literal %s (%s)', (url) => { + expect(validateSafeUrl(url).valid).toBe(false) + }) + + // --- SSRF advisory regression: userinfo decoy must not bypass the host check --- + it('blocks on the real host, ignoring a benign-looking userinfo decoy', () => { + expect(validateSafeUrl('http://trusted.example.com@169.254.169.254/').valid).toBe(false) + expect(validateSafeUrl('http://user:pass@127.0.0.1/').valid).toBe(false) + }) +}) + +/** Deterministic resolver for the SSRF-engine tests: avoids real network/DNS so + * the pin + redirect-revalidation invariants are exercised hermetically. */ +const testLookup: DnsLookup = (host) => { + if (host === 'public.test') { + return Promise.resolve([{ address: '93.184.216.34', family: 4 }]) + } + if (host === 'rebind.test') { + return Promise.resolve([{ address: '169.254.169.254', family: 4 }]) + } + if (host === 'mixed.test') { + return Promise.resolve([ + { address: '93.184.216.34', family: 4 }, + { address: '10.0.0.1', family: 4 }, + ]) + } + // DNS64-style synthesis: AAAA wraps an IPv4 in the NAT64 well-known prefix. + if (host === 'nat64-private.test') { + return Promise.resolve([{ address: '64:ff9b::7f00:1', family: 6 }]) + } + if (host === 'nat64-public.test') { + return Promise.resolve([{ address: '64:ff9b::808:808', family: 6 }]) + } + return Promise.reject(new Error(`ENOTFOUND ${host}`)) +} + +describe('validateAndPin', () => { + it('blocks a public hostname that resolves to a private/metadata address (DNS rebinding)', async () => { + await expect(validateAndPin('http://rebind.test/latest/meta-data/', undefined, testLookup)).rejects.toThrow( + /private\/internal address 169\.254\.169\.254/, + ) + }) + + it('blocks when ANY resolved address is private, even if the first is public', async () => { + await expect(validateAndPin('http://mixed.test/', undefined, testLookup)).rejects.toThrow(/10\.0\.0\.1/) + }) + + it('blocks a hostname whose AAAA wraps a private IPv4 in a NAT64 prefix', async () => { + await expect(validateAndPin('http://nat64-private.test/', undefined, testLookup)).rejects.toThrow(/64:ff9b::7f00:1/) + }) + + it('allows a hostname whose AAAA wraps a PUBLIC IPv4 in a NAT64 prefix (DNS64)', async () => { + const [pinnedUrl, headers] = await validateAndPin('http://nat64-public.test/', undefined, testLookup) + expect(pinnedUrl).toBe('http://[64:ff9b::808:808]/') + expect(headers.get('host')).toBe('nat64-public.test') + }) + + it('pins to the resolved IP and preserves the original Host header', async () => { + const [pinnedUrl, headers] = await validateAndPin('http://public.test/path?q=1', undefined, testLookup) + expect(pinnedUrl).toBe('http://93.184.216.34/path?q=1') + expect(headers.get('host')).toBe('public.test') + }) + + it('strips userinfo before pinning', async () => { + const [pinnedUrl] = await validateAndPin('http://user:pass@public.test/x', undefined, testLookup) + expect(pinnedUrl).toBe('http://93.184.216.34/x') + }) + + it('blocks a private IP literal without resolving DNS', async () => { + await expect(validateAndPin('http://127.0.0.1/', undefined, testLookup)).rejects.toThrow(/private\/internal/) + }) + + it('allows a public IP literal as-is (no Host rewrite needed)', async () => { + const [pinnedUrl] = await validateAndPin('http://93.184.216.34/ok', undefined, testLookup) + expect(pinnedUrl).toBe('http://93.184.216.34/ok') + }) +}) + +const requestUrl = (input: string | URL | Request): string => { + if (typeof input === 'string') { + return input + } + return input instanceof URL ? input.toString() : input.url +} + +/** Build a fetch stub that returns a queued response per call and records the + * URL + Host header each call was made with. */ +const stubFetch = (responses: Response[]) => { + const queue = [...responses] + const calls: Array<{ url: string; host: string | null }> = [] + const fn = ((input: string | URL | Request, init?: RequestInit) => { + calls.push({ url: requestUrl(input), host: new Headers(init?.headers).get('host') }) + return Promise.resolve(queue.shift()) + }) as typeof fetch + return { fn, calls } +} + +describe('createSafeFetch', () => { + it('blocks a redirect hop that points at a loopback address', async () => { + const { fn, calls } = stubFetch([ + new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/admin' } }), + new Response('LEAK', { status: 200 }), + ]) + const safeFetch = createSafeFetch(fn, testLookup) + await expect(safeFetch('http://public.test/')).rejects.toThrow(/private\/internal/) + // Only the first hop was attempted; the loopback hop never reached fetch. + expect(calls).toHaveLength(1) + expect(calls[0].url).toBe('http://93.184.216.34/') + }) + + it('follows a redirect to a public host, re-pinning each hop', async () => { + const { fn, calls } = stubFetch([ + new Response(null, { status: 302, headers: { location: 'http://public.test/next' } }), + new Response('ok', { status: 200 }), + ]) + const safeFetch = createSafeFetch(fn, testLookup) + const res = await safeFetch('http://public.test/') + expect(res.status).toBe(200) + expect(calls.map((c) => c.url)).toEqual(['http://93.184.216.34/', 'http://93.184.216.34/next']) + expect(calls[1].host).toBe('public.test') + }) + + it('returns the redirect unfollowed when the caller asks for manual redirects', async () => { + const { fn, calls } = stubFetch([new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/' } })]) + const safeFetch = createSafeFetch(fn, testLookup) + const res = await safeFetch('http://public.test/', { redirect: 'manual' }) + expect(res.status).toBe(302) + expect(calls).toHaveLength(1) + }) + + it('passes a non-redirect response straight through', async () => { + const { fn, calls } = stubFetch([new Response('body', { status: 200 })]) + const safeFetch = createSafeFetch(fn, testLookup) + const res = await safeFetch('http://public.test/') + expect(res.status).toBe(200) + expect(calls).toHaveLength(1) + expect(calls[0].url).toBe('http://93.184.216.34/') + }) }) diff --git a/backend/src/utils/url-validation.ts b/backend/src/utils/url-validation.ts index 4b71a0ae7..6ae395633 100644 --- a/backend/src/utils/url-validation.ts +++ b/backend/src/utils/url-validation.ts @@ -25,9 +25,44 @@ const blockedRanges = new Set([ 'broadcast', // 255.255.255.255 ]) +type IpAddr = ReturnType + +/** + * Extracts the IPv4 address embedded in an IPv6 transition/translation address, + * or null if none is embedded. A host with the matching connectivity (NAT64/DNS64, + * 6to4, Teredo) routes these to the embedded IPv4 — so the embedded address, including + * a private/internal one, must be re-validated. Blocking the whole range would be wrong: + * on a DNS64 deployment, legitimate public IPv4 sites resolve to `64:ff9b::`. + * (`::ffff:x.x.x.x` is already normalized to IPv4 by `ipaddr.process`, so it's not here.) + * + * The `case` labels are coupled to ipaddr.js's range taxonomy — if an upgrade renames + * or reclassifies these ranges, the `default` branch silently reverts to pre-fix behavior + * (the embedded IPv4 stops being checked). Re-verify these labels when bumping ipaddr.js. + * Operator-configured NAT64 prefixes (RFC 6052 §2.2 non-`/96` variants) are not detectable + * here — only the well-known `64:ff9b::/96` is classified `rfc6052`. + */ +const embeddedIpv4 = (addr: IpAddr): string | null => { + const bytes = addr.toByteArray() + switch (addr.range()) { + case 'rfc6052': // NAT64 64:ff9b::/96 — IPv4 in the low 32 bits + case 'rfc6145': // stateless IPv4/IPv6 translation — IPv4 in the low 32 bits + return bytes.slice(12, 16).join('.') + case '6to4': // 2002::/16 — IPv4 in bytes 2..5 + return bytes.slice(2, 6).join('.') + case 'teredo': // 2001::/32 — client IPv4 in the low 32 bits, one's-complement obfuscated + return bytes + .slice(12, 16) + .map((b) => b ^ 0xff) + .join('.') + default: + return null + } +} + /** * Returns true if the IP address falls within a private/internal/reserved range. - * Handles IPv4, IPv6, IPv4-mapped IPv6 (::ffff:x.x.x.x), and bracketed notation ([::1]). + * Handles IPv4, IPv6, IPv4-mapped IPv6 (::ffff:x.x.x.x), bracketed notation ([::1]), + * and IPv6 transition addresses (NAT64/6to4/Teredo) that embed a private IPv4. */ export const isPrivateAddress = (rawHostname: string): boolean => { const hostname = rawHostname.startsWith('[') && rawHostname.endsWith(']') ? rawHostname.slice(1, -1) : rawHostname @@ -38,7 +73,11 @@ export const isPrivateAddress = (rawHostname: string): boolean => { // process() normalizes IPv4-mapped IPv6 (::ffff:127.0.0.1 / ::ffff:7f00:1) to IPv4 const addr = ipaddr.process(hostname) - return blockedRanges.has(addr.range()) + if (blockedRanges.has(addr.range())) { + return true + } + const embedded = embeddedIpv4(addr) + return embedded !== null && isPrivateAddress(embedded) } /** Returns true if the hostname is a loopback address (127.0.0.0/8, ::1, or localhost). */