Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions backend/src/api/preview.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | null>
expect(data.title).toBeNull()
expect(data.previewImageUrl).toBeNull()
})
})
187 changes: 186 additions & 1 deletion backend/src/utils/url-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down Expand Up @@ -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::<public>.
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([
Expand Down Expand Up @@ -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/')
})
})
43 changes: 41 additions & 2 deletions backend/src/utils/url-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,44 @@ const blockedRanges = new Set([
'broadcast', // 255.255.255.255
])

type IpAddr = ReturnType<typeof ipaddr.process>

/**
* 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::<public-ip>`.
* (`::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
Expand All @@ -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). */
Expand Down
Loading