diff --git a/supabase/functions/_backend/utils/ip.ts b/supabase/functions/_backend/utils/ip.ts new file mode 100644 index 0000000000..a07b0c66ea --- /dev/null +++ b/supabase/functions/_backend/utils/ip.ts @@ -0,0 +1,125 @@ +export function isPrivateIpv4(ip: string) { + if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(ip)) { + return true + } + + const octets = ip.split('.').map(part => Number.parseInt(part, 10)) + if (octets.some(part => Number.isNaN(part) || part < 0 || part > 255)) { + return true + } + + const [a, b, c] = octets + return a === 0 + || a === 10 + || a === 127 + || (a === 169 && b === 254) + || (a === 172 && b >= 16 && b <= 31) + || (a === 192 && b === 168) + || (a === 100 && b >= 64 && b <= 127) + || (a === 198 && (b === 18 || b === 19)) + || (a === 192 && b === 88 && octets[2] === 99) + || a >= 224 + // Reserved TEST-NET ranges are also non-public for server-side fetch paths. + || (a === 192 && b === 0 && c === 0) + || (a === 192 && b === 0 && octets[2] === 2) + || (a === 198 && b === 51 && octets[2] === 100) + || (a === 203 && b === 0 && octets[2] === 113) +} + +export function isPrivateIpv6(ip: string) { + const normalized = ip.toLowerCase() + if (normalized === '::1' || normalized === '::') { + return true + } + if (normalized.startsWith('::ffff:')) { + const mappedIpv4 = normalizeMappedIpv4(normalized.slice(7)) + return isPrivateIpv4(mappedIpv4) + } + + const hextets = parseIpv6Hextets(normalized) + if (!hextets) { + return true + } + + const [firstHextet, secondHextet] = hextets + if ( + (firstHextet & 0xFFC0) === 0xFE80 + || (firstHextet & 0xFFC0) === 0xFEC0 + || (firstHextet & 0xFE00) === 0xFC00 + || (firstHextet & 0xFF00) === 0xFF00 + || firstHextet === 0x2002 + || (firstHextet === 0x2001 && secondHextet === 0x0DB8) + || (firstHextet === 0x2001 && secondHextet === 0x0002) + || (firstHextet === 0x2001 && (secondHextet & 0xFFF0) === 0x0010) + ) { + return true + } + return false +} + +export function isPrivateIp(ip: string) { + return ip.includes(':') ? isPrivateIpv6(ip) : isPrivateIpv4(ip) +} + +function normalizeMappedIpv4(value: string) { + if (value.includes('.')) { + return value + } + + const parts = value.split(':') + if (parts.length !== 2) { + return value + } + + const [high, low] = parts.map(part => Number.parseInt(part, 16)) + if ( + !Number.isFinite(high) + || !Number.isFinite(low) + || high < 0 + || high > 0xFFFF + || low < 0 + || low > 0xFFFF + ) { + return value + } + + return [ + high >> 8, + high & 0xFF, + low >> 8, + low & 0xFF, + ].join('.') +} + +function parseIpv6Hextets(value: string) { + if (value.includes('.')) { + return null + } + + const compressed = value.split('::') + if (compressed.length > 2) { + return null + } + + const [left, right = ''] = compressed + const leftParts = left ? left.split(':') : [] + const rightParts = right ? right.split(':') : [] + const explicitParts = [...leftParts, ...rightParts] + if (explicitParts.some(part => !/^[\da-f]{1,4}$/.test(part))) { + return null + } + + const missingCount = 8 - explicitParts.length + if (compressed.length === 1 && missingCount !== 0) { + return null + } + if (compressed.length === 2 && missingCount < 1) { + return null + } + + return [ + ...leftParts, + ...(Array.from({ length: compressed.length === 2 ? missingCount : 0 }).fill('0') as string[]), + ...rightParts, + ].map(part => Number.parseInt(part, 16)) +} diff --git a/supabase/functions/_backend/utils/publicUrl.ts b/supabase/functions/_backend/utils/publicUrl.ts index 34a3de90aa..6417c362dd 100644 --- a/supabase/functions/_backend/utils/publicUrl.ts +++ b/supabase/functions/_backend/utils/publicUrl.ts @@ -1,3 +1,5 @@ +import { isPrivateIp } from './ip.ts' + const DNS_LOOKUP_URL = 'https://cloudflare-dns.com/dns-query' const DNS_LOOKUP_TIMEOUT_MS = 1500 const DEFAULT_MAX_REDIRECTS = 5 @@ -37,49 +39,6 @@ function isIpLiteral(hostname: string): boolean { return IPV4_REGEX.test(hostname) || hostname.includes(':') } -function isPrivateIpv4(ip: string) { - const octets = ip.split('.').map(part => Number.parseInt(part, 10)) - if (octets.length !== 4 || octets.some(part => Number.isNaN(part) || part < 0 || part > 255)) - return true - - const [a, b, c] = octets - return a === 0 - || a === 10 - || a === 127 - || (a === 100 && b >= 64 && b <= 127) - || (a === 169 && b === 254) - || (a === 172 && b >= 16 && b <= 31) - || (a === 192 && b === 0 && c === 0) - || (a === 192 && b === 0 && c === 2) - || (a === 192 && b === 88 && c === 99) - || (a === 192 && b === 168) - || (a === 198 && (b === 18 || b === 19)) - || (a === 198 && b === 51 && c === 100) - || (a === 203 && b === 0 && c === 113) - || a >= 224 -} - -function isPrivateIpv6(ip: string) { - const normalized = ip.toLowerCase() - - if (normalized === '::' || normalized === '::1') - return true - if (normalized.startsWith('::ffff:')) - return isPrivateIpv4(normalized.slice(7)) - if (normalized.startsWith('100:') || normalized.startsWith('2001:db8:') || normalized.startsWith('2002:')) - return true - if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb')) - return true - if (normalized.startsWith('ff')) - return true - - return false -} - -function isPrivateIp(ip: string) { - return ip.includes(':') ? isPrivateIpv6(ip) : isPrivateIpv4(ip) -} - async function resolveHostnameIps(hostname: string, type: 'A' | 'AAAA') { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), DNS_LOOKUP_TIMEOUT_MS) diff --git a/tests/website-preview-security.unit.test.ts b/tests/website-preview-security.unit.test.ts new file mode 100644 index 0000000000..50d355010c --- /dev/null +++ b/tests/website-preview-security.unit.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { isPrivateIp } from '../supabase/functions/_backend/utils/ip.ts' + +describe('website preview IP validation', () => { + it('rejects non-public IPv4 ranges returned by DNS', () => { + expect(isPrivateIp('10.0.0.1')).toBe(true) + expect(isPrivateIp('127.0.0.1')).toBe(true) + expect(isPrivateIp('169.254.169.254')).toBe(true) + expect(isPrivateIp('172.16.0.1')).toBe(true) + expect(isPrivateIp('192.168.1.1')).toBe(true) + expect(isPrivateIp('100.64.0.1')).toBe(true) + expect(isPrivateIp('198.18.0.1')).toBe(true) + expect(isPrivateIp('192.0.2.1')).toBe(true) + expect(isPrivateIp('198.51.100.1')).toBe(true) + expect(isPrivateIp('203.0.113.1')).toBe(true) + expect(isPrivateIp('192.88.99.1')).toBe(true) + expect(isPrivateIp('224.0.0.1')).toBe(true) + expect(isPrivateIp('240.0.0.1')).toBe(true) + expect(isPrivateIp('255.255.255.255')).toBe(true) + expect(isPrivateIp('1.2.3.foo')).toBe(true) + expect(isPrivateIp('192.0.1.1')).toBe(false) + }) + + it('rejects non-public IPv6 ranges returned by DNS', () => { + expect(isPrivateIp('::')).toBe(true) + expect(isPrivateIp('::1')).toBe(true) + expect(isPrivateIp('fe80::1')).toBe(true) + expect(isPrivateIp('fe90::1')).toBe(true) + expect(isPrivateIp('febf::1')).toBe(true) + expect(isPrivateIp('fec0::1')).toBe(true) + expect(isPrivateIp('feff::1')).toBe(true) + expect(isPrivateIp('fc00::1')).toBe(true) + expect(isPrivateIp('fd00::1')).toBe(true) + expect(isPrivateIp('ff02::1')).toBe(true) + expect(isPrivateIp('2001:db8::1')).toBe(true) + expect(isPrivateIp('2001:0db8::1')).toBe(true) + expect(isPrivateIp('2001:2::1')).toBe(true) + expect(isPrivateIp('2001:0002::1')).toBe(true) + expect(isPrivateIp('2001:10::1')).toBe(true) + expect(isPrivateIp('2001:1f::1')).toBe(true) + expect(isPrivateIp('2002::1')).toBe(true) + expect(isPrivateIp('::ffff:127.0.0.1')).toBe(true) + expect(isPrivateIp('::ffff:7f00:1')).toBe(true) + expect(isPrivateIp('abcd:foo')).toBe(true) + expect(isPrivateIp('2001:::1')).toBe(true) + }) + + it('allows globally routable DNS answers', () => { + expect(isPrivateIp('8.8.8.8')).toBe(false) + expect(isPrivateIp('1.1.1.1')).toBe(false) + expect(isPrivateIp('2606:4700:4700::1111')).toBe(false) + expect(isPrivateIp('2001:4860:4860::8888')).toBe(false) + }) +})