diff --git a/supabase/functions/_backend/private/website_preview.ts b/supabase/functions/_backend/private/website_preview.ts index 19e3b62025..57d1f08300 100644 --- a/supabase/functions/_backend/private/website_preview.ts +++ b/supabase/functions/_backend/private/website_preview.ts @@ -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) @@ -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 @@ -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) diff --git a/supabase/functions/_backend/public/webhooks/deliveries.ts b/supabase/functions/_backend/public/webhooks/deliveries.ts index 79b4fdc720..b3c9cd6f7f 100644 --- a/supabase/functions/_backend/public/webhooks/deliveries.ts +++ b/supabase/functions/_backend/public/webhooks/deliveries.ts @@ -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' @@ -144,7 +144,7 @@ export async function retryDelivery(c: Context 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 }) diff --git a/supabase/functions/_backend/public/webhooks/post.ts b/supabase/functions/_backend/public/webhooks/post.ts index 596cf5051a..6c0edfbdfa 100644 --- a/supabase/functions/_backend/public/webhooks/post.ts +++ b/supabase/functions/_backend/public/webhooks/post.ts @@ -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({ @@ -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 }) diff --git a/supabase/functions/_backend/public/webhooks/put.ts b/supabase/functions/_backend/public/webhooks/put.ts index a20ce2c67e..cc1c5ae76c 100644 --- a/supabase/functions/_backend/public/webhooks/put.ts +++ b/supabase/functions/_backend/public/webhooks/put.ts @@ -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({ @@ -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 }) } diff --git a/supabase/functions/_backend/public/webhooks/test.ts b/supabase/functions/_backend/public/webhooks/test.ts index 88803788ed..b71487cb17 100644 --- a/supabase/functions/_backend/public/webhooks/test.ts +++ b/supabase/functions/_backend/public/webhooks/test.ts @@ -8,7 +8,7 @@ import { createDeliveryRecord, createTestPayload, deliverWebhook, - getWebhookUrlValidationError, + getWebhookUrlValidationErrorAsync, updateDeliveryResult, } from '../../utils/webhook.ts' import { checkWebhookPermissionV2 } from './index.ts' @@ -46,7 +46,7 @@ export async function test(c: Context, 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 }) diff --git a/supabase/functions/_backend/triggers/webhook_dispatcher.ts b/supabase/functions/_backend/triggers/webhook_dispatcher.ts index 3808dde392..512ee2eced 100644 --- a/supabase/functions/_backend/triggers/webhook_dispatcher.ts +++ b/supabase/functions/_backend/triggers/webhook_dispatcher.ts @@ -9,7 +9,7 @@ import { buildWebhookPayload, createDeliveryRecord, findWebhooksForEvent, - getWebhookUrlValidationError, + getWebhookUrlValidationErrorAsync, queueWebhookDelivery, updateDeliveryResult, } from '../utils/webhook.ts' @@ -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, diff --git a/supabase/functions/_backend/utils/ip.ts b/supabase/functions/_backend/utils/ip.ts new file mode 100644 index 0000000000..6f52a2e4ee --- /dev/null +++ b/supabase/functions/_backend/utils/ip.ts @@ -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, 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) + } +} diff --git a/supabase/functions/_backend/utils/webhook.ts b/supabase/functions/_backend/utils/webhook.ts index 96fdb85a03..1f88f12033 100644 --- a/supabase/functions/_backend/utils/webhook.ts +++ b/supabase/functions/_backend/utils/webhook.ts @@ -1,4 +1,5 @@ import type { Context } from 'hono' +import { isIpLiteral, isPrivateIp, resolveHostnameIps } from './ip.ts' import { cloudlog, cloudlogErr, serializeError } from './logging.ts' import { closeClient, getPgClient } from './pg.ts' import { supabaseAdmin } from './supabase.ts' @@ -46,7 +47,6 @@ export const WEBHOOK_EVENT_TYPES = [ export type WebhookEventType = typeof WEBHOOK_EVENT_TYPES[number] const LOCALHOST_SUFFIX = '.localhost' -const IPV4_REGEX = /^\d{1,3}(?:\.\d{1,3}){3}$/ function allowLocalWebhookUrls(c: Context): boolean { return getEnv(c, 'CAPGO_ALLOW_LOCAL_WEBHOOK_URLS') === 'true' @@ -60,10 +60,6 @@ function isLocalhostHostname(hostname: string): boolean { return hostname === 'localhost' || hostname.endsWith(LOCALHOST_SUFFIX) } -function isIpLiteral(hostname: string): boolean { - return IPV4_REGEX.test(hostname) || hostname.includes(':') -} - export function getWebhookUrlValidationError(c: Context, urlString: string): string | null { let url: URL try { @@ -76,9 +72,6 @@ export function getWebhookUrlValidationError(c: Context, urlString: string): str if (allowLocalWebhookUrls(c)) return null - // We intentionally stop at syntactic/public-host checks: webhook delivery runs - // entirely from serverless infrastructure, so private/internal addresses are not - // reachable by design. const hostname = normalizeHostname(url.hostname) if (isLocalhostHostname(hostname)) return 'Webhook URL must point to a public host' @@ -92,6 +85,38 @@ export function getWebhookUrlValidationError(c: Context, urlString: string): str return null } +export async function getWebhookUrlValidationErrorAsync(c: Context, urlString: string): Promise { + const validationError = getWebhookUrlValidationError(c, urlString) + if (validationError) + return validationError + + if (allowLocalWebhookUrls(c)) + return null + + const url = new URL(urlString) + const hostname = normalizeHostname(url.hostname) + const dnsOptions = { + dnsLookupUrl: getEnv(c, 'CAPGO_WEBHOOK_DNS_LOOKUP_URL'), + onError: (error: unknown) => cloudlogErr({ + requestId: c.get('requestId'), + message: 'Webhook DNS validation lookup failed', + hostname, + error: serializeError(error), + }), + } + const ips = [ + ...await resolveHostnameIps(hostname, 'A', dnsOptions), + ...await resolveHostnameIps(hostname, 'AAAA', dnsOptions), + ] + + if (ips.length === 0) + return 'Webhook URL host could not be resolved' + if (ips.some(isPrivateIp)) + return 'Webhook URL must point to a public host' + + return null +} + /** * Build a webhook payload from audit log data */ @@ -215,7 +240,7 @@ export async function deliverWebhook( ): Promise<{ success: boolean, status?: number, body?: string, duration?: number }> { const startTime = Date.now() - const urlValidationError = getWebhookUrlValidationError(c, url) + const urlValidationError = await getWebhookUrlValidationErrorAsync(c, url) if (urlValidationError) { const duration = Date.now() - startTime cloudlogErr({ @@ -250,6 +275,10 @@ export async function deliverWebhook( } try { + // DNS is validated immediately before delivery and redirects are disabled to + // avoid revalidating attacker-controlled Location targets. There is still a + // small DNS-rebinding window between DoH validation and the runtime fetch; + // closing it fully requires connect-time egress enforcement or IP pinning. const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 10000) // 10s timeout diff --git a/tests/webhook-url-validation.test.ts b/tests/webhook-url-validation.test.ts new file mode 100644 index 0000000000..36d2993ecf --- /dev/null +++ b/tests/webhook-url-validation.test.ts @@ -0,0 +1,206 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +import { deliverWebhook, getWebhookUrlValidationError, getWebhookUrlValidationErrorAsync } from '../supabase/functions/_backend/utils/webhook.ts' + +const context = { env: {}, get: () => 'test-request-id' } as any +const dnsAnswers = new Map() +const deliveryResponses = new Map() + +function mockDnsAnswers(hostname: string, answers: string[], options: { status?: number } = {}) { + dnsAnswers.set(hostname, { + answers, + status: options.status ?? 200, + }) +} + +function mockDnsThenDelivery(hostname: string, answers: string[], deliveryUrl: string, deliveryResponse: Response) { + mockDnsAnswers(hostname, answers) + deliveryResponses.set(deliveryUrl, deliveryResponse) +} + +describe('webhook URL validation', () => { + beforeAll(() => { + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (!url.startsWith('https://cloudflare-dns.com/')) { + const response = deliveryResponses.get(url) + if (response) + return response + + return new Response('', { status: 404 }) + } + + const hostname = new URL(url).searchParams.get('name') ?? '' + const record = dnsAnswers.get(hostname) ?? { answers: [], status: 200 } + const recordType = new URL(url).searchParams.get('type') + const data = recordType === 'A' || recordType === 'AAAA' + ? record.answers.map(answer => ({ data: answer })) + : [] + + return new Response(JSON.stringify({ Answer: data }), { + status: record.status, + headers: { 'content-type': 'application/json' }, + }) + })) + }) + + afterAll(() => { + vi.unstubAllGlobals() + }) + + it.concurrent('keeps blocking direct IP webhook URLs', () => { + expect(getWebhookUrlValidationError(context, 'https://127.0.0.1/webhook')).toBe('Webhook URL must use a hostname, not an IP address') + }) + + it.concurrent('blocks hostnames that resolve to private network addresses', async () => { + mockDnsAnswers('internal.example.com', ['10.0.0.5']) + + await expect( + getWebhookUrlValidationErrorAsync(context, 'https://internal.example.com/webhook'), + ) + .resolves + .toBe('Webhook URL must point to a public host') + }) + + it.concurrent('blocks hostnames with both public and private DNS answers', async () => { + mockDnsAnswers('mixed.example.com', ['93.184.216.34', '192.168.1.10']) + + await expect( + getWebhookUrlValidationErrorAsync(context, 'https://mixed.example.com/webhook'), + ) + .resolves + .toBe('Webhook URL must point to a public host') + }) + + it.concurrent('blocks multicast and reserved IPv4 answers', async () => { + mockDnsAnswers('reserved.example.com', ['224.0.0.1', '240.0.0.1']) + + await expect( + getWebhookUrlValidationErrorAsync(context, 'https://reserved.example.com/webhook'), + ) + .resolves + .toBe('Webhook URL must point to a public host') + }) + + it.concurrent('blocks IPv6 link-local addresses across fe80::/10', async () => { + mockDnsAnswers('link-local.example.com', ['fea0::1']) + + await expect( + getWebhookUrlValidationErrorAsync(context, 'https://link-local.example.com/webhook'), + ) + .resolves + .toBe('Webhook URL must point to a public host') + }) + + it.concurrent('blocks IPv6 discard-only prefix 100::/64 in abbreviated forms', async () => { + mockDnsAnswers('discard.example.com', ['100::1', '0100::']) + + await expect( + getWebhookUrlValidationErrorAsync(context, 'https://discard.example.com/webhook'), + ) + .resolves + .toBe('Webhook URL must point to a public host') + }) + + it.concurrent('blocks IPv6 NAT64 prefix 64:ff9b::/96 with leading zeros', async () => { + mockDnsAnswers('nat64.example.com', ['64:ff9b::1234:5678', '0064:ff9b::8888:8888']) + + await expect( + getWebhookUrlValidationErrorAsync(context, 'https://nat64.example.com/webhook'), + ) + .resolves + .toBe('Webhook URL must point to a public host') + }) + + it.concurrent('blocks IPv6 documentation prefix 2001:db8::/32 with leading zeros', async () => { + mockDnsAnswers('docs.example.com', ['2001:db8::1', '2001:0db8::']) + + await expect( + getWebhookUrlValidationErrorAsync(context, 'https://docs.example.com/webhook'), + ) + .resolves + .toBe('Webhook URL must point to a public host') + }) + + it.concurrent('blocks IPv6 multicast addresses ff00::/8', async () => { + mockDnsAnswers('multicast.example.com', ['ff02::1', 'ff00::']) + + await expect( + getWebhookUrlValidationErrorAsync(context, 'https://multicast.example.com/webhook'), + ) + .resolves + .toBe('Webhook URL must point to a public host') + }) + + it.concurrent('allows public IPv4-mapped IPv6 answers encoded as hex pairs', async () => { + mockDnsAnswers('mapped.example.com', ['::ffff:0808:0808']) + + await expect( + getWebhookUrlValidationErrorAsync(context, 'https://mapped.example.com/webhook'), + ) + .resolves + .toBeNull() + }) + + it.concurrent('fails closed when the DNS resolver returns no answers', async () => { + mockDnsAnswers('empty.example.com', []) + + await expect( + getWebhookUrlValidationErrorAsync(context, 'https://empty.example.com/webhook'), + ) + .resolves + .toBe('Webhook URL host could not be resolved') + }) + + it.concurrent('fails closed when the DNS resolver returns an error status', async () => { + mockDnsAnswers('dns-error.example.com', [], { status: 503 }) + + await expect( + getWebhookUrlValidationErrorAsync(context, 'https://dns-error.example.com/webhook'), + ) + .resolves + .toBe('Webhook URL host could not be resolved') + }) + + it.concurrent('allows hostnames that resolve to public addresses', async () => { + mockDnsAnswers('example.com', ['93.184.216.34']) + + await expect( + getWebhookUrlValidationErrorAsync(context, 'https://example.com/webhook'), + ) + .resolves + .toBeNull() + }) + + it.concurrent('does not follow webhook delivery redirects', async () => { + const deliveryUrl = 'https://redirect.example.com/webhook' + mockDnsThenDelivery('redirect.example.com', ['93.184.216.34'], deliveryUrl, new Response('', { + status: 302, + headers: { Location: 'http://127.0.0.1/internal' }, + })) + + const result = await deliverWebhook( + context, + 'delivery-id', + deliveryUrl, + { + event: 'apps.INSERT', + event_id: 'event-id', + timestamp: '2026-05-12T00:00:00.000Z', + org_id: 'org-id', + data: { + table: 'apps', + operation: 'INSERT', + record_id: 'app-id', + old_record: null, + new_record: null, + changed_fields: null, + }, + }, + 'secret', + ) + + expect(result).toMatchObject({ success: false, status: 302 }) + const deliveryCall = vi.mocked(fetch).mock.calls.find(([url]) => url === deliveryUrl) + expect(deliveryCall?.[1]).toMatchObject({ redirect: 'manual' }) + }) +})