Skip to content
Open
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
63 changes: 2 additions & 61 deletions supabase/functions/_backend/private/website_preview.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { Context } from 'hono'
import { createHono, middlewareAuth, parseBody, quickError, useCors } from '../utils/hono.ts'
import { isPrivateIp, resolveHostnameIps } from '../utils/ip.ts'
import { version } from '../utils/version.ts'
import { getWebhookUrlValidationError } from '../utils/webhook.ts'

const MAX_ICON_BYTES = 512 * 1024
const MAX_HTML_BYTES = 1024 * 1024
const MAX_REDIRECTS = 5
const DNS_LOOKUP_URL = 'https://cloudflare-dns.com/dns-query'

export const app = createHono('', version)

Expand Down Expand Up @@ -73,7 +73,7 @@ function normalizeCandidateName(value: string, hostname: string, options?: { pre
}

const parts = options?.preferLeadingSegment
? trimmed.split(/\s[|·•:–—-]\s|[|·•:–—]/).map(part => part.trim()).filter(Boolean)
? trimmed.split(/\s[|·•:–—-]\s|[|·•:–—-]/).map(part => part.trim()).filter(Boolean)
: [trimmed]

const candidate = parts[0] ?? trimmed
Expand Down Expand Up @@ -134,65 +134,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(':')
}

async function resolveHostnameIps(hostname: string, type: 'A' | 'AAAA') {
const dnsUrl = new URL(DNS_LOOKUP_URL)
dnsUrl.searchParams.set('name', hostname)
dnsUrl.searchParams.set('type', type)

const response = await fetch(dnsUrl.toString(), {
headers: { Accept: 'application/dns-json' },
})
if (!response.ok)
return []

const data = await response.json() as { Answer?: Array<{ data?: string }> }
return (data.Answer ?? [])
.map(answer => answer.data?.trim() ?? '')
.filter(answer => !!answer && isIpLiteral(answer))
}

async function getPublicHostnameValidationError(c: Context, urlString: string) {
const validationError = getWebhookUrlValidationError(c, urlString)
if (validationError)
Expand Down
4 changes: 2 additions & 2 deletions supabase/functions/_backend/public/webhooks/deliveries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { supabaseApikey, supabaseWithAuth } from '../../utils/supabase.ts'
import {
getDeliveryById,
getWebhookById,
getWebhookUrlValidationError,
getWebhookUrlValidationErrorAsync,
queueWebhookDelivery,
} from '../../utils/webhook.ts'
import { checkWebhookPermission, checkWebhookPermissionV2 } from './index.ts'
Expand Down Expand Up @@ -144,7 +144,7 @@ export async function retryDelivery(c: Context<MiddlewareKeyVariables, any, any>
throw simpleError('webhook_disabled', 'Webhook is disabled')
}

const urlError = getWebhookUrlValidationError(c, webhook.url)
const urlError = await getWebhookUrlValidationErrorAsync(c, webhook.url)
if (urlError)
throw simpleError('invalid_url', urlError, { url: webhook.url })

Expand Down
4 changes: 2 additions & 2 deletions supabase/functions/_backend/public/webhooks/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { type } from 'arktype'
import { safeParseSchema } from '../../utils/ark_validation.ts'
import { simpleError } from '../../utils/hono.ts'
import { supabaseApikey } from '../../utils/supabase.ts'
import { getWebhookUrlValidationError, WEBHOOK_EVENT_TYPES } from '../../utils/webhook.ts'
import { getWebhookUrlValidationErrorAsync, WEBHOOK_EVENT_TYPES } from '../../utils/webhook.ts'
import { checkWebhookPermission } from './index.ts'

const bodySchema = type({
Expand Down Expand Up @@ -33,7 +33,7 @@ export async function post(c: Context, bodyRaw: any, apikey: Database['public'][
})
}

const urlError = getWebhookUrlValidationError(c, body.url)
const urlError = await getWebhookUrlValidationErrorAsync(c, body.url)
if (urlError)
throw simpleError('invalid_url', urlError, { url: body.url })

Expand Down
4 changes: 2 additions & 2 deletions supabase/functions/_backend/public/webhooks/put.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { type } from 'arktype'
import { safeParseSchema } from '../../utils/ark_validation.ts'
import { simpleError } from '../../utils/hono.ts'
import { supabaseApikey } from '../../utils/supabase.ts'
import { getWebhookUrlValidationError, WEBHOOK_EVENT_TYPES } from '../../utils/webhook.ts'
import { getWebhookUrlValidationErrorAsync, WEBHOOK_EVENT_TYPES } from '../../utils/webhook.ts'
import { checkWebhookPermission } from './index.ts'

const bodySchema = type({
Expand Down Expand Up @@ -57,7 +57,7 @@ export async function put(c: Context, bodyRaw: any, apikey: Database['public']['

// Validate URL if provided
if (body.url) {
const urlError = getWebhookUrlValidationError(c, body.url)
const urlError = await getWebhookUrlValidationErrorAsync(c, body.url)
if (urlError)
throw simpleError('invalid_url', urlError, { url: body.url })
}
Expand Down
4 changes: 2 additions & 2 deletions supabase/functions/_backend/public/webhooks/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
createDeliveryRecord,
createTestPayload,
deliverWebhook,
getWebhookUrlValidationError,
getWebhookUrlValidationErrorAsync,
updateDeliveryResult,
} from '../../utils/webhook.ts'
import { checkWebhookPermissionV2 } from './index.ts'
Expand Down Expand Up @@ -46,7 +46,7 @@ export async function test(c: Context<MiddlewareKeyVariables, any, any>, bodyRaw
throw simpleError('no_permission', 'Webhook does not belong to this organization', { webhookId: body.webhookId })
}

const urlError = getWebhookUrlValidationError(c, webhook.url)
const urlError = await getWebhookUrlValidationErrorAsync(c, webhook.url)
if (urlError)
throw simpleError('invalid_url', urlError, { url: webhook.url })

Expand Down
4 changes: 2 additions & 2 deletions supabase/functions/_backend/triggers/webhook_dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
buildWebhookPayload,
createDeliveryRecord,
findWebhooksForEvent,
getWebhookUrlValidationError,
getWebhookUrlValidationErrorAsync,
queueWebhookDelivery,
updateDeliveryResult,
} from '../utils/webhook.ts'
Expand Down Expand Up @@ -99,7 +99,7 @@ app.post('/', middlewareAPISecret, async (c) => {
return
}

const urlError = getWebhookUrlValidationError(c, webhook.url)
const urlError = await getWebhookUrlValidationErrorAsync(c, webhook.url)
if (urlError) {
await updateDeliveryResult(
c,
Expand Down
156 changes: 156 additions & 0 deletions supabase/functions/_backend/utils/ip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
export type DnsRecordType = 'A' | 'AAAA'

export const DEFAULT_DNS_LOOKUP_URL = 'https://cloudflare-dns.com/dns-query'
const DEFAULT_DNS_LOOKUP_TIMEOUT_MS = 2_000
const IPV4_REGEX = /^\d{1,3}(?:\.\d{1,3}){3}$/

interface ResolveHostnameIpsOptions {
dnsLookupUrl?: string
onError?: (_error: unknown) => void
timeoutMs?: number
}

function parseIpv4Octets(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 null
return octets
}

function isPrivateIpv4(ip: string) {
const octets = parseIpv4Octets(ip)
if (!octets)
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 === 0 && c === 0)
|| (a === 192 && b === 0 && c === 2)
|| (a === 198 && b === 51 && c === 100)
|| (a === 203 && b === 0 && c === 113)
|| a >= 224
}

function parseIpv4MappedIpv6Tail(tail: string) {
if (tail.includes('.'))
return tail

const parts = tail.split(':')
if (parts.length !== 2)
return null

const words = parts.map(part => Number.parseInt(part, 16))
if (words.some(word => Number.isNaN(word) || word < 0 || word > 0xFFFF))
return null

return [
words[0] >> 8,
words[0] & 0xFF,
words[1] >> 8,
words[1] & 0xFF,
].join('.')
}

function parseFirstHextet(ip: string) {
const first = ip.split(':')[0] || '0'
const hextet = Number.parseInt(first, 16)
return Number.isNaN(hextet) ? null : hextet
}

function parseIpv6Hextets(ip: string) {
const parts = ip.split('::')
if (parts.length > 2)
return null

const left = parts[0] ? parts[0].split(':') : []
const right = parts.length === 2 && parts[1] ? parts[1].split(':') : []
if (left.length + right.length > 8)
return null

const zeroFill = parts.length === 2 ? 8 - left.length - right.length : 0
const hextetStrings = [
...left,
...Array.from({ length: zeroFill }).fill('0'),
...right,
]
if (hextetStrings.length !== 8 || hextetStrings.some(part => !/^[0-9a-f]{1,4}$/i.test(part)))
return null

return hextetStrings.map((part) => {
const hextet = Number.parseInt(part, 16)
return Number.isNaN(hextet) ? null : hextet
})
}

function hasIpv6Prefix(hextets: Array<number | null>, prefix: number[]) {
return prefix.every((hextet, index) => hextets[index] === hextet)
}

function isPrivateIpv6(ip: string) {
const normalized = ip.toLowerCase()
if (normalized === '::1' || normalized === '::')
return true

if (normalized.startsWith('::ffff:')) {
const mappedIpv4 = parseIpv4MappedIpv6Tail(normalized.slice(7))
return mappedIpv4 ? isPrivateIpv4(mappedIpv4) : true
}

const firstHextet = parseFirstHextet(normalized)
const hextets = parseIpv6Hextets(normalized)
if (firstHextet === null || !hextets)
return true

return (firstHextet & 0xFFC0) === 0xFE80
|| (firstHextet & 0xFFC0) === 0xFEC0
|| (firstHextet & 0xFE00) === 0xFC00
|| (firstHextet & 0xFF00) === 0xFF00
|| hasIpv6Prefix(hextets, [0x0100, 0, 0, 0])
|| hasIpv6Prefix(hextets, [0x0064, 0xFF9B, 0, 0, 0, 0])
|| hasIpv6Prefix(hextets, [0x2001, 0x0DB8])
}

export function isIpLiteral(value: string) {
return IPV4_REGEX.test(value) || value.includes(':')
}

export function isPrivateIp(ip: string) {
return ip.includes(':') ? isPrivateIpv6(ip) : isPrivateIpv4(ip)
}

export async function resolveHostnameIps(hostname: string, type: DnsRecordType, options: ResolveHostnameIpsOptions = {}) {
const dnsUrl = new URL(options.dnsLookupUrl || DEFAULT_DNS_LOOKUP_URL)
dnsUrl.searchParams.set('name', hostname)
dnsUrl.searchParams.set('type', type)

const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_DNS_LOOKUP_TIMEOUT_MS)

try {
const response = await fetch(dnsUrl.toString(), {
headers: { Accept: 'application/dns-json' },
signal: controller.signal,
})
if (!response.ok)
return []

const data = await response.json() as { Answer?: Array<{ data?: string }> }
return (data.Answer ?? [])
.map(answer => answer.data?.trim() ?? '')
.filter(answer => !!answer && isIpLiteral(answer))
}
catch (error) {
options.onError?.(error)
return []
}
finally {
clearTimeout(timeoutId)
}
}
Loading