From b520dae510408eebcc2a18c1f2df8d72b83f4615 Mon Sep 17 00:00:00 2001 From: popka13221 <168582558+popka13221@users.noreply.github.com> Date: Sun, 10 May 2026 23:53:22 +0200 Subject: [PATCH] Harden website preview IP validation --- .../_backend/private/website_preview.ts | 39 +----- supabase/functions/_backend/utils/ip.ts | 125 ++++++++++++++++++ tests/website-preview-security.unit.test.ts | 54 ++++++++ 3 files changed, 180 insertions(+), 38 deletions(-) create mode 100644 supabase/functions/_backend/utils/ip.ts create mode 100644 tests/website-preview-security.unit.test.ts diff --git a/supabase/functions/_backend/private/website_preview.ts b/supabase/functions/_backend/private/website_preview.ts index 19e3b62025..f5cb7ec20d 100644 --- a/supabase/functions/_backend/private/website_preview.ts +++ b/supabase/functions/_backend/private/website_preview.ts @@ -1,5 +1,6 @@ import type { Context } from 'hono' import { createHono, middlewareAuth, parseBody, quickError, useCors } from '../utils/hono.ts' +import { isPrivateIp } from '../utils/ip.ts' import { version } from '../utils/version.ts' import { getWebhookUrlValidationError } from '../utils/webhook.ts' @@ -134,44 +135,6 @@ function findIconHref(html: string) { return candidates.sort((a, b) => b.priority - a.priority)[0]?.href ?? '' } -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] = 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)) - // Reserved TEST-NET ranges are also non-public for this fetch path. - || (a === 192 && b === 0) - || (a === 192 && b === 0 && octets[2] === 2) - || (a === 198 && b === 51 && octets[2] === 100) - || (a === 203 && b === 0 && octets[2] === 113) -} - -function isPrivateIpv6(ip: string) { - const normalized = ip.toLowerCase() - if (normalized === '::1' || normalized === '::') - return true - if (normalized.startsWith('fe80:') || normalized.startsWith('fc') || normalized.startsWith('fd')) - return true - if (normalized.startsWith('::ffff:')) { - const mappedIpv4 = normalized.slice(7) - return isPrivateIpv4(mappedIpv4) - } - return false -} - -function isPrivateIp(ip: string) { - return ip.includes(':') ? isPrivateIpv6(ip) : isPrivateIpv4(ip) -} - function isIpLiteral(value: string) { return /^[0-9.]+$/.test(value) || value.includes(':') } 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/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) + }) +})