diff --git a/supabase/functions/_backend/utils/ip.ts b/supabase/functions/_backend/utils/ip.ts index 6f52a2e4ee..fcc6f0e30c 100644 --- a/supabase/functions/_backend/utils/ip.ts +++ b/supabase/functions/_backend/utils/ip.ts @@ -77,7 +77,7 @@ function parseIpv6Hextets(ip: string) { const zeroFill = parts.length === 2 ? 8 - left.length - right.length : 0 const hextetStrings = [ ...left, - ...Array.from({ length: zeroFill }).fill('0'), + ...(Array.from({ length: zeroFill }).fill('0') as string[]), ...right, ] if (hextetStrings.length !== 8 || hextetStrings.some(part => !/^[0-9a-f]{1,4}$/i.test(part))) diff --git a/supabase/functions/_backend/utils/webhook.ts b/supabase/functions/_backend/utils/webhook.ts index 1f88f12033..0c1515bd9e 100644 --- a/supabase/functions/_backend/utils/webhook.ts +++ b/supabase/functions/_backend/utils/webhook.ts @@ -1,4 +1,6 @@ import type { Context } from 'hono' +import type { TLSSocket } from 'node:tls' +import { connect } from 'node:tls' import { isIpLiteral, isPrivateIp, resolveHostnameIps } from './ip.ts' import { cloudlog, cloudlogErr, serializeError } from './logging.ts' import { closeClient, getPgClient } from './pg.ts' @@ -47,6 +49,27 @@ export const WEBHOOK_EVENT_TYPES = [ export type WebhookEventType = typeof WEBHOOK_EVENT_TYPES[number] const LOCALHOST_SUFFIX = '.localhost' +const WEBHOOK_DELIVERY_TIMEOUT_MS = 10_000 +const WEBHOOK_RESPONSE_BODY_LIMIT = 10_000 +const WEBHOOK_RESPONSE_READ_LIMIT = 64_000 +const HTTP_RESPONSE_SEPARATOR = '\r\n\r\n' + +interface ValidatedWebhookTarget { + hostname: string + ips: string[] + port: number + url: URL +} + +interface LocalWebhookTarget { + localFetch: true + url: URL +} + +interface WebhookDeliveryHttpResponse { + body: string + status: number +} function allowLocalWebhookUrls(c: Context): boolean { return getEnv(c, 'CAPGO_ALLOW_LOCAL_WEBHOOK_URLS') === 'true' @@ -86,14 +109,23 @@ export function getWebhookUrlValidationError(c: Context, urlString: string): str } export async function getWebhookUrlValidationErrorAsync(c: Context, urlString: string): Promise { + const target = await getValidatedWebhookTarget(c, urlString) + return target.error +} + +async function getValidatedWebhookTarget( + c: Context, + urlString: string, +): Promise<{ error: string | null, target?: ValidatedWebhookTarget | LocalWebhookTarget }> { const validationError = getWebhookUrlValidationError(c, urlString) if (validationError) - return validationError + return { error: validationError } + + const url = new URL(urlString) if (allowLocalWebhookUrls(c)) - return null + return { error: null, target: { localFetch: true, url } } - const url = new URL(urlString) const hostname = normalizeHostname(url.hostname) const dnsOptions = { dnsLookupUrl: getEnv(c, 'CAPGO_WEBHOOK_DNS_LOOKUP_URL'), @@ -110,11 +142,19 @@ export async function getWebhookUrlValidationErrorAsync(c: Context, urlString: s ] if (ips.length === 0) - return 'Webhook URL host could not be resolved' + return { error: 'Webhook URL host could not be resolved' } if (ips.some(isPrivateIp)) - return 'Webhook URL must point to a public host' + return { error: 'Webhook URL must point to a public host' } - return null + return { + error: null, + target: { + hostname, + ips, + port: url.port ? Number.parseInt(url.port, 10) : 443, + url, + }, + } } /** @@ -228,6 +268,241 @@ export async function generateWebhookSignature( return `v1=${timestamp}.${hexSignature}` } +function getWebhookRequestPath(url: URL) { + return `${url.pathname || '/'}${url.search}` +} + +function sanitizeHttpHeaderValue(value: string) { + return value.replace(/[\r\n]/g, '') +} + +function buildPinnedWebhookRequest( + target: ValidatedWebhookTarget, + headers: Record, + body: string, +) { + const encodedBody = new TextEncoder().encode(body) + const requestHeaders = { + ...headers, + 'Host': target.url.host, + 'Content-Length': String(encodedBody.byteLength), + 'Connection': 'close', + } + + const headerLines = Object.entries(requestHeaders) + .map(([name, value]) => `${name}: ${sanitizeHttpHeaderValue(value)}`) + .join('\r\n') + + return [ + `POST ${getWebhookRequestPath(target.url)} HTTP/1.1`, + headerLines, + '', + body, + ].join('\r\n') +} + +function concatChunks(chunks: Uint8Array[], totalLength: number) { + const result = new Uint8Array(totalLength) + let offset = 0 + for (const chunk of chunks) { + result.set(chunk, offset) + offset += chunk.byteLength + } + return result +} + +function readSocketResponse(socket: TLSSocket): Promise { + return new Promise((resolve, reject) => { + const chunks: Uint8Array[] = [] + let totalLength = 0 + let settled = false + let timeoutId: ReturnType + + function cleanup() { + clearTimeout(timeoutId) + socket.off('data', onData) + socket.off('end', onEnd) + socket.off('error', onError) + } + + function settle(callback: () => void) { + if (settled) + return + settled = true + cleanup() + callback() + } + + function onData(chunk: Uint8Array | string) { + const data = typeof chunk === 'string' ? new TextEncoder().encode(chunk) : new Uint8Array(chunk) + const remaining = WEBHOOK_RESPONSE_READ_LIMIT - totalLength + if (remaining > 0) { + const chunkToStore = data.byteLength > remaining ? data.slice(0, remaining) : data + chunks.push(chunkToStore) + totalLength += chunkToStore.byteLength + } + + if (totalLength >= WEBHOOK_RESPONSE_READ_LIMIT) + settle(() => resolve(concatChunks(chunks, totalLength))) + } + + function onEnd() { + settle(() => resolve(concatChunks(chunks, totalLength))) + } + + function onError(error: Error) { + settle(() => reject(error)) + } + + timeoutId = setTimeout(() => { + socket.destroy(new Error('Webhook delivery timed out')) + }, WEBHOOK_DELIVERY_TIMEOUT_MS) + + socket.on('data', onData) + socket.once('end', onEnd) + socket.once('error', onError) + }) +} + +function decodeChunkedBody(body: string) { + let offset = 0 + let decoded = '' + + while (offset < body.length) { + const sizeEnd = body.indexOf('\r\n', offset) + if (sizeEnd === -1) + return body + + const sizeText = body.slice(offset, sizeEnd).split(';')[0]?.trim() ?? '' + const size = Number.parseInt(sizeText, 16) + if (Number.isNaN(size)) + return body + if (size === 0) + return decoded + + const chunkStart = sizeEnd + 2 + decoded += body.slice(chunkStart, chunkStart + size) + offset = chunkStart + size + 2 + } + + return decoded +} + +function parsePinnedWebhookResponse(rawBytes: Uint8Array): WebhookDeliveryHttpResponse { + const rawResponse = new TextDecoder().decode(rawBytes) + const separatorIndex = rawResponse.indexOf(HTTP_RESPONSE_SEPARATOR) + if (separatorIndex === -1) + throw new Error('Webhook delivery returned an invalid HTTP response') + + const headerText = rawResponse.slice(0, separatorIndex) + const responseLines = headerText.split('\r\n') + const statusLine = responseLines.shift() ?? '' + const statusMatch = /^HTTP\/\d(?:\.\d)?\s+(\d{3})\b/.exec(statusLine) + if (!statusMatch) + throw new Error('Webhook delivery returned an invalid HTTP status line') + + const responseHeaders = new Map() + for (const line of responseLines) { + const colonIndex = line.indexOf(':') + if (colonIndex === -1) + continue + responseHeaders.set(line.slice(0, colonIndex).trim().toLowerCase(), line.slice(colonIndex + 1).trim()) + } + + const encodedBody = rawResponse.slice(separatorIndex + HTTP_RESPONSE_SEPARATOR.length) + const body = responseHeaders.get('transfer-encoding')?.toLowerCase().includes('chunked') + ? decodeChunkedBody(encodedBody) + : encodedBody + + return { + status: Number.parseInt(statusMatch[1], 10), + body: body.slice(0, WEBHOOK_RESPONSE_BODY_LIMIT), + } +} + +function openPinnedTlsSocket(target: ValidatedWebhookTarget, ip: string): Promise { + return new Promise((resolve, reject) => { + let timeoutId: ReturnType + const socket = connect({ + host: ip, + port: target.port, + rejectUnauthorized: true, + servername: target.hostname, + }, () => { + cleanup() + resolve(socket) + }) + + function cleanup() { + clearTimeout(timeoutId) + socket.off('error', onError) + } + + function onError(error: Error) { + cleanup() + socket.destroy() + reject(error) + } + + timeoutId = setTimeout(() => { + socket.destroy(new Error('Webhook delivery timed out')) + }, WEBHOOK_DELIVERY_TIMEOUT_MS) + + socket.once('error', onError) + }) +} + +async function postWebhookToPinnedIp( + target: ValidatedWebhookTarget, + headers: Record, + body: string, +) { + let lastError: unknown + + for (const ip of target.ips) { + try { + const socket = await openPinnedTlsSocket(target, ip) + try { + socket.write(buildPinnedWebhookRequest(target, headers, body)) + socket.end() + + return { + ip, + response: parsePinnedWebhookResponse(await readSocketResponse(socket)), + } + } + finally { + socket.destroy() + } + } + catch (error) { + lastError = error + } + } + + throw lastError instanceof Error ? lastError : new Error('Webhook delivery failed') +} + +async function fetchLocalWebhook( + url: string, + headers: Record, + body: string, + signal: AbortSignal, +) { + const response = await fetch(url, { + method: 'POST', + headers, + body, + redirect: 'manual', + signal, + }) + + return { + body: (await response.text()).slice(0, WEBHOOK_RESPONSE_BODY_LIMIT), + status: response.status, + } +} + /** * Deliver a webhook to the user's endpoint */ @@ -240,21 +515,21 @@ export async function deliverWebhook( ): Promise<{ success: boolean, status?: number, body?: string, duration?: number }> { const startTime = Date.now() - const urlValidationError = await getWebhookUrlValidationErrorAsync(c, url) - if (urlValidationError) { + const validatedTarget = await getValidatedWebhookTarget(c, url) + if (validatedTarget.error || !validatedTarget.target) { const duration = Date.now() - startTime cloudlogErr({ requestId: c.get('requestId'), message: 'Webhook delivery blocked by URL validation', deliveryId, url, - error: urlValidationError, + error: validatedTarget.error, duration, }) return { success: false, - body: `Error: ${urlValidationError}`, + body: `Error: ${validatedTarget.error}`, duration, } } @@ -275,40 +550,40 @@ 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 - - const response = await fetch(url, { - method: 'POST', - headers, - body: payloadString, - redirect: 'manual', - signal: controller.signal, - }) - - clearTimeout(timeoutId) - const duration = Date.now() - startTime - const responseBody = await response.text() - - cloudlog({ - requestId: c.get('requestId'), - message: 'Webhook delivery attempt', - deliveryId, - url, - status: response.status, - success: response.ok, - duration, - }) + const timeoutId = setTimeout(() => controller.abort(), WEBHOOK_DELIVERY_TIMEOUT_MS) - return { - success: response.ok, - status: response.status, - body: responseBody.slice(0, 10000), // Limit stored body size - duration, + try { + const delivery = 'localFetch' in validatedTarget.target + ? { + ip: 'local', + response: await fetchLocalWebhook(url, headers, payloadString, controller.signal), + } + : await postWebhookToPinnedIp(validatedTarget.target, headers, payloadString) + + const duration = Date.now() - startTime + const success = delivery.response.status >= 200 && delivery.response.status < 300 + + cloudlog({ + requestId: c.get('requestId'), + message: 'Webhook delivery attempt', + deliveryId, + url, + resolvedIp: delivery.ip, + status: delivery.response.status, + success, + duration, + }) + + return { + success, + status: delivery.response.status, + body: delivery.response.body, + duration, + } + } + finally { + clearTimeout(timeoutId) } } catch (error) { diff --git a/tests/webhook-delivery-redirect.unit.test.ts b/tests/webhook-delivery-redirect.unit.test.ts index 36475d16e9..ac64193535 100644 --- a/tests/webhook-delivery-redirect.unit.test.ts +++ b/tests/webhook-delivery-redirect.unit.test.ts @@ -1,9 +1,15 @@ +import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCloudlog, mockCloudlogErr, mockGetEnv } = vi.hoisted(() => ({ +const { mockCloudlog, mockCloudlogErr, mockGetEnv, mockTlsConnect } = vi.hoisted(() => ({ mockCloudlog: vi.fn(), mockCloudlogErr: vi.fn(), mockGetEnv: vi.fn(), + mockTlsConnect: vi.fn(), +})) + +vi.mock('node:tls', () => ({ + connect: mockTlsConnect, })) vi.mock('../supabase/functions/_backend/utils/logging.ts', () => ({ @@ -17,6 +23,67 @@ vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({ })) const { deliverWebhook } = await import('../supabase/functions/_backend/utils/webhook.ts') +const dnsAnswers = new Map() +const tlsResponses = new Map() +const tlsSockets: MockTlsSocket[] = [] + +class MockTlsSocket extends EventEmitter { + writes: string[] = [] + + constructor(private readonly host: string) { + super() + } + + write(chunk: string | Uint8Array) { + this.writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)) + return true + } + + end() { + const response = tlsResponses.get(this.host) ?? 'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok' + queueMicrotask(() => { + this.emit('data', new TextEncoder().encode(response)) + this.emit('end') + }) + return this + } + + destroy(error?: Error) { + if (error) + queueMicrotask(() => this.emit('error', error)) + return this + } +} + +function mockDns(hostname: string, answers: string[]) { + dnsAnswers.set(hostname, answers) +} + +function mockDnsFetch() { + return vi.fn(async (url: string | URL | Request) => { + const urlString = typeof url === 'string' + ? url + : url instanceof URL + ? url.toString() + : url.url + + if (!urlString.startsWith('https://cloudflare-dns.com/')) + throw new Error(`Unexpected delivery fetch to ${urlString}`) + + const parsedUrl = new URL(urlString) + const hostname = parsedUrl.searchParams.get('name') ?? '' + const type = parsedUrl.searchParams.get('type') + const answers = (dnsAnswers.get(hostname) ?? []) + .filter(answer => type === 'AAAA' ? answer.includes(':') : !answer.includes(':')) + + return new Response(JSON.stringify({ + Answer: answers.map(data => ({ data })), + }), { + headers: { 'content-type': 'application/json' }, + status: 200, + }) + }) +} describe('webhook delivery redirect handling', () => { const payload = { @@ -39,23 +106,38 @@ describe('webhook delivery redirect handling', () => { } beforeEach(() => { + dnsAnswers.clear() + tlsResponses.clear() + tlsSockets.length = 0 mockCloudlog.mockReset() mockCloudlogErr.mockReset() mockGetEnv.mockReset() mockGetEnv.mockReturnValue(null) + mockTlsConnect.mockReset() + mockTlsConnect.mockImplementation((options: { host: string }, onConnect?: () => void) => { + const socket = new MockTlsSocket(options.host) + tlsSockets.push(socket) + queueMicrotask(() => onConnect?.()) + return socket + }) }) afterEach(() => { vi.restoreAllMocks() + vi.unstubAllGlobals() }) - it.concurrent('uses manual redirect mode for outbound webhook delivery', async () => { - const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('redirect blocked', { - status: 302, - headers: { - location: 'http://169.254.169.254/latest/meta-data', - }, - })) + it('keeps redirects manual for pinned outbound webhook delivery', async () => { + mockDns('example.com', ['93.184.216.34']) + tlsResponses.set('93.184.216.34', [ + 'HTTP/1.1 302 Found', + 'Location: http://169.254.169.254/latest/meta-data', + 'Content-Length: 16', + '', + 'redirect blocked', + ].join('\r\n')) + const fetchMock = mockDnsFetch() + vi.stubGlobal('fetch', fetchMock) const result = await deliverWebhook( context as any, @@ -65,13 +147,16 @@ describe('webhook delivery redirect handling', () => { 'whsec_test_secret', ) - expect(fetchMock).toHaveBeenCalledWith( - 'https://example.com/webhook', + expect(mockTlsConnect).toHaveBeenCalledWith( expect.objectContaining({ - method: 'POST', - redirect: 'manual', + host: '93.184.216.34', + port: 443, + servername: 'example.com', }), + expect.any(Function), ) + expect(fetchMock.mock.calls.every(([url]) => String(url).startsWith('https://cloudflare-dns.com/'))).toBe(true) + expect(tlsSockets[0]?.writes.join('')).toContain('Host: example.com') expect(result).toMatchObject({ success: false, status: 302, diff --git a/tests/webhook-delivery-security.unit.test.ts b/tests/webhook-delivery-security.unit.test.ts index 4375d8d22d..d768cb4447 100644 --- a/tests/webhook-delivery-security.unit.test.ts +++ b/tests/webhook-delivery-security.unit.test.ts @@ -1,9 +1,15 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetEnv, mockCloudlog, mockCloudlogErr } = vi.hoisted(() => ({ +const { mockGetEnv, mockCloudlog, mockCloudlogErr, mockTlsConnect } = vi.hoisted(() => ({ mockGetEnv: vi.fn(), mockCloudlog: vi.fn(), mockCloudlogErr: vi.fn(), + mockTlsConnect: vi.fn(), +})) + +vi.mock('node:tls', () => ({ + connect: mockTlsConnect, })) vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({ @@ -22,19 +28,94 @@ function createContext() { } as any } -afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals() +const dnsAnswers = new Map() +const tlsResponses = new Map() +const tlsSockets: MockTlsSocket[] = [] + +class MockTlsSocket extends EventEmitter { + writes: string[] = [] + + constructor(private readonly host: string) { + super() + } + + write(chunk: string | Uint8Array) { + this.writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)) + return true + } + + end() { + const response = tlsResponses.get(this.host) ?? 'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok' + queueMicrotask(() => { + this.emit('data', new TextEncoder().encode(response)) + this.emit('end') + }) + return this + } + + destroy(error?: Error) { + if (error) + queueMicrotask(() => this.emit('error', error)) + return this + } +} + +function mockDns(hostname: string, answers: string[]) { + dnsAnswers.set(hostname, answers) +} + +function mockDnsFetch() { + return vi.fn(async (url: string | URL | Request) => { + const urlString = typeof url === 'string' + ? url + : url instanceof URL + ? url.toString() + : url.url + + if (!urlString.startsWith('https://cloudflare-dns.com/')) + throw new Error(`Unexpected delivery fetch to ${urlString}`) + + const parsedUrl = new URL(urlString) + const hostname = parsedUrl.searchParams.get('name') ?? '' + const type = parsedUrl.searchParams.get('type') + const answers = (dnsAnswers.get(hostname) ?? []) + .filter(answer => type === 'AAAA' ? answer.includes(':') : !answer.includes(':')) + + return new Response(JSON.stringify({ + Answer: answers.map(data => ({ data })), + }), { + headers: { 'content-type': 'application/json' }, + status: 200, + }) + }) +} + +beforeEach(() => { + dnsAnswers.clear() + tlsResponses.clear() + tlsSockets.length = 0 mockGetEnv.mockReset() mockCloudlog.mockReset() mockCloudlogErr.mockReset() mockGetEnv.mockReturnValue('') + mockTlsConnect.mockReset() + mockTlsConnect.mockImplementation((options: { host: string }, onConnect?: () => void) => { + const socket = new MockTlsSocket(options.host) + tlsSockets.push(socket) + queueMicrotask(() => onConnect?.()) + return socket + }) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() }) describe('webhook delivery redirect handling', () => { - it('sends webhook requests with manual redirect handling', async () => { - mockGetEnv.mockReturnValue('') - const fetchMock = vi.fn().mockResolvedValue(new Response('ok', { status: 200 })) + it('pins webhook delivery to the validated public DNS answer', async () => { + mockDns('example.com', ['93.184.216.34']) + const fetchMock = mockDnsFetch() vi.stubGlobal('fetch', fetchMock) const { deliverWebhook } = await import('../supabase/functions/_backend/utils/webhook.ts') @@ -60,21 +141,28 @@ describe('webhook delivery redirect handling', () => { ) expect(result.success).toBe(true) - expect(fetchMock).toHaveBeenCalledOnce() - expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ - method: 'POST', - redirect: 'manual', - }) + expect(mockTlsConnect).toHaveBeenCalledWith( + expect.objectContaining({ + host: '93.184.216.34', + port: 443, + servername: 'example.com', + }), + expect.any(Function), + ) + expect(tlsSockets[0]?.writes.join('')).toContain('Host: example.com') + expect(fetchMock.mock.calls.every(([url]) => String(url).startsWith('https://cloudflare-dns.com/'))).toBe(true) }) it('does not treat redirect responses as successful deliveries', async () => { - mockGetEnv.mockReturnValue('') - const fetchMock = vi.fn().mockResolvedValue(new Response('', { - status: 302, - headers: { - location: 'http://169.254.169.254/latest/meta-data/', - }, - })) + mockDns('example.com', ['93.184.216.34']) + tlsResponses.set('93.184.216.34', [ + 'HTTP/1.1 302 Found', + 'Location: http://169.254.169.254/latest/meta-data/', + 'Content-Length: 0', + '', + '', + ].join('\r\n')) + const fetchMock = mockDnsFetch() vi.stubGlobal('fetch', fetchMock) const { deliverWebhook } = await import('../supabase/functions/_backend/utils/webhook.ts') @@ -101,6 +189,6 @@ describe('webhook delivery redirect handling', () => { expect(result.success).toBe(false) expect(result.status).toBe(302) - expect(fetchMock).toHaveBeenCalledOnce() + expect(fetchMock.mock.calls.every(([url]) => String(url).startsWith('https://cloudflare-dns.com/'))).toBe(true) }) }) diff --git a/tests/webhook-url-validation.test.ts b/tests/webhook-url-validation.test.ts index 36d2993ecf..4d61c1fe59 100644 --- a/tests/webhook-url-validation.test.ts +++ b/tests/webhook-url-validation.test.ts @@ -1,10 +1,52 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { deliverWebhook, getWebhookUrlValidationError, getWebhookUrlValidationErrorAsync } from '../supabase/functions/_backend/utils/webhook.ts' +const { mockTlsConnect } = vi.hoisted(() => ({ + mockTlsConnect: vi.fn(), +})) + +vi.mock('node:tls', () => ({ + connect: mockTlsConnect, +})) const context = { env: {}, get: () => 'test-request-id' } as any const dnsAnswers = new Map() const deliveryResponses = new Map() +const tlsResponses = new Map() +const tlsSockets: MockTlsSocket[] = [] +let webhookUtils: typeof import('../supabase/functions/_backend/utils/webhook.ts') + +class MockTlsSocket extends EventEmitter { + writes: string[] = [] + + constructor(private readonly host: string) { + super() + } + + write(chunk: string | Uint8Array) { + this.writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)) + return true + } + + end() { + const response = tlsResponses.get(this.host) ?? 'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok' + queueMicrotask(() => { + this.emit('data', new TextEncoder().encode(response)) + this.emit('end') + }) + return this + } + + setTimeout() { + return this + } + + destroy(error?: Error) { + if (error) + queueMicrotask(() => this.emit('error', error)) + return this + } +} function mockDnsAnswers(hostname: string, answers: string[], options: { status?: number } = {}) { dnsAnswers.set(hostname, { @@ -19,7 +61,14 @@ function mockDnsThenDelivery(hostname: string, answers: string[], deliveryUrl: s } describe('webhook URL validation', () => { - beforeAll(() => { + beforeAll(async () => { + mockTlsConnect.mockImplementation((options: { host: string }, onConnect?: () => void) => { + const socket = new MockTlsSocket(options.host) + tlsSockets.push(socket) + queueMicrotask(() => onConnect?.()) + return socket + }) + vi.stubGlobal('fetch', vi.fn(async (url: string) => { if (!url.startsWith('https://cloudflare-dns.com/')) { const response = deliveryResponses.get(url) @@ -41,144 +90,189 @@ describe('webhook URL validation', () => { headers: { 'content-type': 'application/json' }, }) })) + + webhookUtils = await import('../supabase/functions/_backend/utils/webhook.ts') + }) + + beforeEach(() => { + tlsResponses.clear() + tlsSockets.length = 0 + mockTlsConnect.mockClear() + vi.mocked(fetch).mockClear() }) 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('keeps blocking direct IP webhook URLs', () => { + expect(webhookUtils.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 () => { + it('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'), + webhookUtils.getWebhookUrlValidationErrorAsync(context, 'https://internal.example.com/webhook'), + ) + .resolves + .toBe('Webhook URL must point to a public host') + }) + + it('blocks hostnames that resolve to loopback addresses', async () => { + mockDnsAnswers('loopback.example.com', ['127.0.0.1']) + + await expect( + webhookUtils.getWebhookUrlValidationErrorAsync(context, 'https://loopback.example.com/webhook'), + ) + .resolves + .toBe('Webhook URL must point to a public host') + }) + + it('blocks hostnames that resolve to RFC1918 172.16/12 addresses', async () => { + mockDnsAnswers('rfc1918.example.com', ['172.16.0.1', '172.31.255.255']) + + await expect( + webhookUtils.getWebhookUrlValidationErrorAsync(context, 'https://rfc1918.example.com/webhook'), + ) + .resolves + .toBe('Webhook URL must point to a public host') + }) + + it('blocks hostnames that resolve to IPv4 link-local metadata addresses', async () => { + mockDnsAnswers('metadata.example.com', ['169.254.169.254']) + + await expect( + webhookUtils.getWebhookUrlValidationErrorAsync(context, 'https://metadata.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 () => { + it('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'), + webhookUtils.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 () => { + it('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'), + webhookUtils.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 () => { + it('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'), + webhookUtils.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 () => { + it('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'), + webhookUtils.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 () => { + it('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'), + webhookUtils.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 () => { + it('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'), + webhookUtils.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 () => { + it('blocks IPv6 multicast addresses ff00::/8', async () => { mockDnsAnswers('multicast.example.com', ['ff02::1', 'ff00::']) await expect( - getWebhookUrlValidationErrorAsync(context, 'https://multicast.example.com/webhook'), + webhookUtils.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 () => { + it('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'), + webhookUtils.getWebhookUrlValidationErrorAsync(context, 'https://mapped.example.com/webhook'), ) .resolves .toBeNull() }) - it.concurrent('fails closed when the DNS resolver returns no answers', async () => { + it('fails closed when the DNS resolver returns no answers', async () => { mockDnsAnswers('empty.example.com', []) await expect( - getWebhookUrlValidationErrorAsync(context, 'https://empty.example.com/webhook'), + webhookUtils.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 () => { + it('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'), + webhookUtils.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 () => { + it('allows hostnames that resolve to public addresses', async () => { mockDnsAnswers('example.com', ['93.184.216.34']) await expect( - getWebhookUrlValidationErrorAsync(context, 'https://example.com/webhook'), + webhookUtils.getWebhookUrlValidationErrorAsync(context, 'https://example.com/webhook'), ) .resolves .toBeNull() }) - it.concurrent('does not follow webhook delivery redirects', async () => { + it('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' }, + mockDnsThenDelivery('redirect.example.com', ['93.184.216.34'], deliveryUrl, new Response('legacy fetch path should not be used', { + status: 200, })) - - const result = await deliverWebhook( + tlsResponses.set('93.184.216.34', [ + 'HTTP/1.1 302 Found', + 'Location: http://127.0.0.1/internal', + 'Content-Length: 0', + '', + '', + ].join('\r\n')) + + const result = await webhookUtils.deliverWebhook( context, 'delivery-id', deliveryUrl, @@ -200,7 +294,52 @@ describe('webhook URL validation', () => { ) expect(result).toMatchObject({ success: false, status: 302 }) - const deliveryCall = vi.mocked(fetch).mock.calls.find(([url]) => url === deliveryUrl) - expect(deliveryCall?.[1]).toMatchObject({ redirect: 'manual' }) + }) + + it('pins delivery to the DNS answer that passed validation', async () => { + const deliveryUrl = 'https://rebind.example.com/webhook' + mockDnsThenDelivery('rebind.example.com', ['93.184.216.35'], deliveryUrl, new Response('runtime resolver should not be used', { + status: 200, + })) + tlsResponses.set('93.184.216.35', [ + 'HTTP/1.1 200 OK', + 'Content-Length: 2', + '', + 'ok', + ].join('\r\n')) + + const result = await webhookUtils.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: true, status: 200, body: 'ok' }) + expect(mockTlsConnect).toHaveBeenCalledWith( + expect.objectContaining({ + host: '93.184.216.35', + port: 443, + rejectUnauthorized: true, + servername: 'rebind.example.com', + }), + expect.any(Function), + ) + expect(tlsSockets[0]?.writes.join('')).toContain('Host: rebind.example.com') + expect(vi.mocked(fetch).mock.calls.some(([url]) => url === deliveryUrl)).toBe(false) }) })