diff --git a/server/supabase/functions/_shared/webpush-endpoint-policy.ts b/server/supabase/functions/_shared/webpush-endpoint-policy.ts new file mode 100644 index 0000000..3a93a40 --- /dev/null +++ b/server/supabase/functions/_shared/webpush-endpoint-policy.ts @@ -0,0 +1,78 @@ +// server/supabase/functions/_shared/webpush-endpoint-policy.ts +// Pure policy: which Web Push subscription endpoints the server may POST to. +// +// A Web Push `endpoint` is supplied by the client when it registers, so it is +// untrusted input. Without a host allowlist the send path becomes a blind SSRF +// primitive (the edge function POSTs to any https URL and its outcome leaks +// back as distinct error codes). Only the browser vendors' push services are +// legitimate targets, so everything else is refused before any network IO. + +export type WebPushHostRule = + | { readonly kind: 'exact'; readonly host: string } + | { readonly kind: 'subdomain'; readonly suffix: string } + +/** Push services operated by the browser vendors (Chrome/Edge/Firefox/Safari). */ +export const WEBPUSH_ALLOWED_HOST_RULES: readonly WebPushHostRule[] = Object.freeze([ + { kind: 'exact', host: 'fcm.googleapis.com' }, + { kind: 'exact', host: 'updates.push.services.mozilla.com' }, + { kind: 'subdomain', suffix: '.push.services.mozilla.com' }, + { kind: 'subdomain', suffix: '.notify.windows.com' }, + { kind: 'exact', host: 'web.push.apple.com' }, +]) + +export type WebPushEndpointRejection = + | 'endpoint_unparseable' + | 'endpoint_not_https' + | 'endpoint_has_credentials' + | 'endpoint_non_default_port' + | 'endpoint_ip_literal' + | 'endpoint_host_not_allowed' + +export type WebPushEndpointVerdict = + | { readonly allowed: true; readonly url: URL } + | { readonly allowed: false; readonly reason: WebPushEndpointRejection } + +const IPV4_LITERAL = /^\d{1,3}(\.\d{1,3}){3}$/ + +function isIpLiteral(hostname: string): boolean { + // WHATWG URL normalises every numeric IPv4 spelling (0x7f.1, 2130706433, …) + // to dotted-quad for special schemes, and IPv6 hosts keep their brackets. + return hostname.startsWith('[') || IPV4_LITERAL.test(hostname) +} + +function matchesRule(hostname: string, rule: WebPushHostRule): boolean { + if (rule.kind === 'exact') return hostname === rule.host + return hostname.length > rule.suffix.length && hostname.endsWith(rule.suffix) +} + +export function isAllowedWebPushHost( + hostname: string, + rules: readonly WebPushHostRule[] = WEBPUSH_ALLOWED_HOST_RULES, +): boolean { + const normalized = hostname.toLowerCase() + if (isIpLiteral(normalized)) return false + return rules.some((rule) => matchesRule(normalized, rule)) +} + +export function evaluateWebPushEndpoint( + endpoint: string, + rules: readonly WebPushHostRule[] = WEBPUSH_ALLOWED_HOST_RULES, +): WebPushEndpointVerdict { + let url: URL + try { + url = new URL(endpoint) + } catch { + return { allowed: false, reason: 'endpoint_unparseable' } + } + if (url.protocol !== 'https:') return { allowed: false, reason: 'endpoint_not_https' } + if (url.username !== '' || url.password !== '') { + return { allowed: false, reason: 'endpoint_has_credentials' } + } + // WHATWG URL drops the scheme's default port, so any non-empty port is not 443. + if (url.port !== '') return { allowed: false, reason: 'endpoint_non_default_port' } + if (isIpLiteral(url.hostname)) return { allowed: false, reason: 'endpoint_ip_literal' } + if (!isAllowedWebPushHost(url.hostname, rules)) { + return { allowed: false, reason: 'endpoint_host_not_allowed' } + } + return { allowed: true, url } +} diff --git a/server/supabase/functions/_shared/webpush-redteam-r1-19.test.ts b/server/supabase/functions/_shared/webpush-redteam-r1-19.test.ts new file mode 100644 index 0000000..242ad61 --- /dev/null +++ b/server/supabase/functions/_shared/webpush-redteam-r1-19.test.ts @@ -0,0 +1,159 @@ +// server/supabase/functions/_shared/webpush-redteam-r1-19.test.ts +// Regression: a user-supplied Web Push endpoint must never make the edge +// function POST to an arbitrary host (SSRF with a status oracle). + +import { parseWebPushSubscription, sendWebPushMessage, type WebPushConfig } from './webpush.ts' +import { + evaluateWebPushEndpoint, + isAllowedWebPushHost, + type WebPushEndpointRejection, +} from './webpush-endpoint-policy.ts' +import { PushContractError, type PushNotification } from './push-contract.ts' + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +function b64url(bytes: Uint8Array): string { + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') +} + +async function makeKeys(): Promise<{ p256dh: string; auth: string }> { + const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']) + const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)) + return { p256dh: b64url(raw), auth: b64url(crypto.getRandomValues(new Uint8Array(16))) } +} + +async function makeVapidConfig(): Promise { + const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']) + const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)) + const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey) + assert(typeof jwk.d === 'string', 'private scalar must be exportable') + return { publicKey: b64url(publicRaw), privateKey: jwk.d, subject: 'mailto:push@d3ro.test' } +} + +const notification: PushNotification = { + title: 'Transcription complete', + body: 'Open D3RO Voice', + data: { + schema_version: '1', + event_type: 'transcription.completed', + resource_id: '11111111-2222-4333-8444-555555555555', + route: 'HistoryDetail', + history_id: '11111111-2222-4333-8444-555555555555', + }, +} + +const HOSTILE_ENDPOINTS: ReadonlyArray<[string, WebPushEndpointRejection]> = [ + ['https://internal-host:8443/x', 'endpoint_non_default_port'], + ['https://internal-host/x', 'endpoint_host_not_allowed'], + ['https://127.0.0.1/x', 'endpoint_ip_literal'], + ['https://0x7f.1/x', 'endpoint_ip_literal'], + ['https://2130706433/x', 'endpoint_ip_literal'], + ['https://[::1]/x', 'endpoint_ip_literal'], + ['https://169.254.169.254/latest/meta-data', 'endpoint_ip_literal'], + ['https://fcm.googleapis.com:8443/fcm/send/abc', 'endpoint_non_default_port'], + ['https://user:pw@fcm.googleapis.com/fcm/send/abc', 'endpoint_has_credentials'], + ['https://fcm.googleapis.com.attacker.example/fcm/send/abc', 'endpoint_host_not_allowed'], + ['https://evilfcm.googleapis.com/fcm/send/abc', 'endpoint_host_not_allowed'], + ['https://attacker.example/notify.windows.com', 'endpoint_host_not_allowed'], + ['https://notify.windows.com/x', 'endpoint_host_not_allowed'], + ['https://push.example.com/subscriptions/abc123', 'endpoint_host_not_allowed'], + ['http://fcm.googleapis.com/fcm/send/abc', 'endpoint_not_https'], + ['not a url', 'endpoint_unparseable'], +] + +const LEGITIMATE_ENDPOINTS = [ + 'https://fcm.googleapis.com/fcm/send/abc123', + 'https://FCM.googleapis.com:443/fcm/send/abc123', + 'https://updates.push.services.mozilla.com/wpush/v2/gAAAA', + 'https://updates-autopush.push.services.mozilla.com/wpush/v2/gAAAA', + 'https://wns2-by3p.notify.windows.com/w/?token=BQYAAA', + 'https://web.push.apple.com/QGuQyavXutnMH', +] + +Deno.test('endpoint policy rejects hosts outside the push service allowlist', () => { + for (const [endpoint, reason] of HOSTILE_ENDPOINTS) { + const verdict = evaluateWebPushEndpoint(endpoint) + assert(!verdict.allowed, `${endpoint} must be rejected`) + assert(verdict.reason === reason, `${endpoint}: expected ${reason}, got ${verdict.reason}`) + } +}) + +Deno.test('endpoint policy accepts the browser vendor push services', () => { + for (const endpoint of LEGITIMATE_ENDPOINTS) { + const verdict = evaluateWebPushEndpoint(endpoint) + assert(verdict.allowed, `${endpoint} must be allowed`) + } + assert(isAllowedWebPushHost('WEB.PUSH.APPLE.COM'), 'host match is case-insensitive') + assert(!isAllowedWebPushHost('10.0.0.1'), 'IP literal is never an allowed host') +}) + +Deno.test('subscription parser refuses a hostile endpoint as a stale registration', async () => { + const keys = await makeKeys() + for (const [endpoint] of HOSTILE_ENDPOINTS) { + let caught: unknown + try { + parseWebPushSubscription(JSON.stringify({ endpoint, keys })) + } catch (error) { + caught = error + } + assert(caught instanceof PushContractError, `${endpoint} must throw`) + assert(caught.code === 'webpush_registration_invalid', `${endpoint}: code ${caught.code}`) + assert(caught.staleRegistration === true, `${endpoint}: must be purged as stale`) + } + const parsed = parseWebPushSubscription( + JSON.stringify({ endpoint: 'https://fcm.googleapis.com:443/fcm/send/abc123', keys }), + ) + assert(parsed.endpoint === 'https://fcm.googleapis.com/fcm/send/abc123', 'endpoint normalized') +}) + +Deno.test('send never reaches the network for a hostile endpoint', async () => { + const keys = await makeKeys() + const config = await makeVapidConfig() + let fetchCalls = 0 + const spyFetch = (() => { + fetchCalls += 1 + return Promise.resolve(new Response(null, { status: 201 })) + }) as unknown as typeof fetch + + let caught: unknown + try { + await sendWebPushMessage( + JSON.stringify({ endpoint: 'https://internal-host:8443/x', keys }), + notification, + { config, fetchImpl: spyFetch }, + ) + } catch (error) { + caught = error + } + assert(caught instanceof PushContractError, 'hostile endpoint throws') + assert(caught.code === 'webpush_registration_invalid', `unexpected code ${caught.code}`) + assert(caught.staleRegistration === true, 'hostile registration is purged, not retried') + assert(fetchCalls === 0, 'no request may be sent to a hostile endpoint') +}) + +Deno.test('send refuses to follow redirects off the push service', async () => { + const keys = await makeKeys() + const config = await makeVapidConfig() + let redirectMode: RequestRedirect | undefined + const spyFetch = ((_input: string, init?: RequestInit) => { + redirectMode = init?.redirect + return Promise.resolve(new Response(null, { status: 302, headers: { Location: 'https://internal-host/' } })) + }) as unknown as typeof fetch + + let caught: unknown + try { + await sendWebPushMessage( + JSON.stringify({ endpoint: 'https://fcm.googleapis.com/fcm/send/abc123', keys }), + notification, + { config, fetchImpl: spyFetch }, + ) + } catch (error) { + caught = error + } + assert(redirectMode === 'manual', 'redirects must not be followed automatically') + assert(caught instanceof PushContractError && caught.code === 'webpush_send_failed', 'redirect is a send failure') +}) diff --git a/server/supabase/functions/_shared/webpush.test.ts b/server/supabase/functions/_shared/webpush.test.ts index 101b092..23d8d0b 100644 --- a/server/supabase/functions/_shared/webpush.test.ts +++ b/server/supabase/functions/_shared/webpush.test.ts @@ -114,7 +114,7 @@ async function makeSubscription(): Promise<{ registrationId: string; subscriptio const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', uaKeys.publicKey)) const auth = crypto.getRandomValues(new Uint8Array(16)) const subscription: WebPushSubscription = { - endpoint: 'https://push.example.com/subscriptions/abc123', + endpoint: 'https://fcm.googleapis.com/fcm/send/abc123', p256dh: b64url(publicRaw), auth: b64url(auth), } diff --git a/server/supabase/functions/_shared/webpush.ts b/server/supabase/functions/_shared/webpush.ts index 834e74c..74dc17c 100644 --- a/server/supabase/functions/_shared/webpush.ts +++ b/server/supabase/functions/_shared/webpush.ts @@ -5,6 +5,7 @@ // VAPID and encrypt per RFC 8291. No Google account or Firebase project needed. import { PushContractError, type PushNotification } from './push-contract.ts' +import { evaluateWebPushEndpoint } from './webpush-endpoint-policy.ts' export interface WebPushConfig { /** base64url encoded uncompressed P-256 public key (65 bytes, 0x04 prefix). */ @@ -117,6 +118,17 @@ export function readWebPushConfig( } } +function readSubscriptionKey(keys: unknown, name: 'p256dh' | 'auth'): string { + if (!keys || typeof keys !== 'object') return '' + const value = (keys as Record)[name] + return typeof value === 'string' ? value : '' +} + +/** + * Parses a stored Web Push subscription. The endpoint must pass the push + * service allowlist (see webpush-endpoint-policy.ts); anything else is + * treated as a stale registration so it is purged without any network IO. + */ export function parseWebPushSubscription(registrationId: string): WebPushSubscription { if (typeof registrationId !== 'string' || registrationId.length < 20 || registrationId.length > 8192) { throw new PushContractError('webpush_registration_invalid', 400, true) @@ -124,18 +136,13 @@ export function parseWebPushSubscription(registrationId: string): WebPushSubscri try { const parsed = JSON.parse(registrationId) as Record const endpoint = typeof parsed.endpoint === 'string' ? parsed.endpoint : '' - const keys = parsed.keys - const p256dh = keys && typeof keys === 'object' && typeof (keys as Record).p256dh === 'string' - ? (keys as Record).p256dh - : '' - const auth = keys && typeof keys === 'object' && typeof (keys as Record).auth === 'string' - ? (keys as Record).auth - : '' - const endpointUrl = new URL(endpoint) + const p256dh = readSubscriptionKey(parsed.keys, 'p256dh') + const auth = readSubscriptionKey(parsed.keys, 'auth') + const verdict = evaluateWebPushEndpoint(endpoint) const p256dhBytes = decodeBase64Url(p256dh) const authBytes = decodeBase64Url(auth) if ( - endpointUrl.protocol !== 'https:' + !verdict.allowed || p256dhBytes.byteLength !== 65 || p256dhBytes[0] !== 0x04 || authBytes.byteLength < 16 @@ -143,7 +150,7 @@ export function parseWebPushSubscription(registrationId: string): WebPushSubscri ) { throw new Error('invalid subscription') } - return { endpoint: endpointUrl.toString(), p256dh, auth } + return { endpoint: verdict.url.toString(), p256dh, auth } } catch (error) { if (error instanceof PushContractError) throw error throw new PushContractError('webpush_registration_invalid', 400, true) @@ -289,6 +296,8 @@ export async function sendWebPushMessage( TTL: '86400', }, body, + // Never follow a redirect off the allowlisted push service host. + redirect: 'manual', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }) } catch { diff --git a/server/supabase/functions/send-push/dispatch-response.test.ts b/server/supabase/functions/send-push/dispatch-response.test.ts new file mode 100644 index 0000000..a6ccb29 --- /dev/null +++ b/server/supabase/functions/send-push/dispatch-response.test.ts @@ -0,0 +1,84 @@ +// server/supabase/functions/send-push/dispatch-response.test.ts +// The dispatch presenter must not leak provider transport codes to end users. + +import { presentDispatchOutcome, type DispatchOutcome } from './dispatch-response.ts' + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +const EVENT = 'transcription.completed' +const RESOURCE = '11111111-2222-4333-8444-555555555555' + +function outcome(overrides: Partial = {}): DispatchOutcome { + return { + status: 'succeeded', + complete: true, + attempted: 1, + delivered: 1, + stale: 0, + retryableFailed: 0, + permanentFailed: 0, + nextRetryAt: null, + transientError: null, + transientStatus: null, + ...overrides, + } +} + +const TRANSIENT_CASES: ReadonlyArray<[string, number]> = [ + ['webpush_send_timeout', 504], + ['webpush_send_failed', 502], + ['webpush_payload_too_large', 500], + ['webpush_not_configured', 503], + ['fcm_send_failed', 502], +] + +Deno.test('user callers see one generic failure regardless of the transport outcome', () => { + const seen = new Set() + for (const [code, status] of TRANSIENT_CASES) { + const presented = presentDispatchOutcome('user', EVENT, RESOURCE, outcome({ + status: 'pending', + complete: false, + delivered: 0, + retryableFailed: 1, + transientError: code, + transientStatus: status, + })) + assert(presented.body.error === 'push_delivery_failed', `${code} leaked as ${String(presented.body.error)}`) + assert(!JSON.stringify(presented.body).includes(code), `${code} must not appear in the body`) + seen.add(`${presented.status}:${String(presented.body.error)}`) + } + assert(seen.size === 1, `user responses must be indistinguishable, got ${[...seen].join(', ')}`) +}) + +Deno.test('system callers keep the detailed transport code and status', () => { + for (const [code, status] of TRANSIENT_CASES) { + const presented = presentDispatchOutcome('system', EVENT, RESOURCE, outcome({ + status: 'pending', + complete: false, + delivered: 0, + retryableFailed: 1, + transientError: code, + transientStatus: status, + })) + assert(presented.body.error === code, `system caller expects ${code}`) + assert(presented.status === status, `system caller expects status ${status}`) + } +}) + +Deno.test('success, pending and partial responses keep their existing shape', () => { + const ok = presentDispatchOutcome('user', EVENT, RESOURCE, outcome()) + assert(ok.status === 200, 'complete dispatch is 200') + assert(!('error' in ok.body), 'success carries no error') + assert(ok.body.sent === 1 && ok.body.event_type === EVENT && ok.body.resource_id === RESOURCE, 'body fields preserved') + assert(ok.body.duplicate === false, 'duplicate flag preserved') + + const pending = presentDispatchOutcome('system', EVENT, RESOURCE, outcome({ status: 'processing', complete: false })) + assert(pending.status === 202, 'incomplete dispatch is 202') + + for (const audience of ['user', 'system'] as const) { + const partial = presentDispatchOutcome(audience, EVENT, RESOURCE, outcome({ status: 'partial', permanentFailed: 1 })) + assert(partial.status === 502 && partial.body.error === 'push_delivery_failed', `${audience} partial is 502`) + } +}) diff --git a/server/supabase/functions/send-push/dispatch-response.ts b/server/supabase/functions/send-push/dispatch-response.ts new file mode 100644 index 0000000..0742c23 --- /dev/null +++ b/server/supabase/functions/send-push/dispatch-response.ts @@ -0,0 +1,74 @@ +// server/supabase/functions/send-push/dispatch-response.ts +// Pure presenter: turns a processed push dispatch into the HTTP body/status. +// +// Provider transport codes (webpush_send_timeout, fcm_send_failed, …) and +// their distinct HTTP statuses describe what happened on the function's own +// network. Echoing them to an end-user caller turns the dispatch endpoint into +// a reachability oracle, so user callers only ever see one generic failure. +// Trusted system callers (service role, drain worker) keep the detailed code +// for operations. + +export type DispatchAudience = 'user' | 'system' + +export interface DispatchOutcome { + status: 'pending' | 'processing' | 'succeeded' | 'partial' | 'failed' + complete: boolean + attempted: number + delivered: number + stale: number + retryableFailed: number + permanentFailed: number + nextRetryAt: string | null + transientError: string | null + transientStatus: number | null +} + +export interface DispatchResponse { + body: Record + status: number +} + +export const DELIVERY_FAILURE_CODE = 'push_delivery_failed' +const DELIVERY_FAILURE_STATUS = 502 + +export function presentDispatchOutcome( + audience: DispatchAudience, + eventType: string, + resourceId: string, + outcome: DispatchOutcome, +): DispatchResponse { + const body: Record = { + event_type: eventType, + resource_id: resourceId, + duplicate: false, + status: outcome.status, + complete: outcome.complete, + attempted: outcome.attempted, + sent: outcome.delivered, + stale_removed: outcome.stale, + retryable_failed: outcome.retryableFailed, + permanent_failed: outcome.permanentFailed, + next_retry_at: outcome.nextRetryAt, + } + const failed = outcome.status === 'partial' || outcome.status === 'failed' + + if (outcome.transientError) { + if (audience === 'user') { + return { + body: { error: DELIVERY_FAILURE_CODE, ...body }, + status: DELIVERY_FAILURE_STATUS, + } + } + return { + body: { error: outcome.transientError, ...body }, + status: outcome.transientStatus ?? DELIVERY_FAILURE_STATUS, + } + } + if (failed) { + return { + body: { error: DELIVERY_FAILURE_CODE, ...body }, + status: DELIVERY_FAILURE_STATUS, + } + } + return { body, status: outcome.complete ? 200 : 202 } +} diff --git a/server/supabase/functions/send-push/index.ts b/server/supabase/functions/send-push/index.ts index b2fd0f3..efb8f8d 100644 --- a/server/supabase/functions/send-push/index.ts +++ b/server/supabase/functions/send-push/index.ts @@ -19,6 +19,7 @@ import { import { readApnsConfig, sendApnsMessage } from '../_shared/apns.ts' import { readWebPushConfig, sendWebPushMessage } from '../_shared/webpush.ts' import { createServiceRoleClient } from '../_shared/quota.ts' +import { presentDispatchOutcome } from './dispatch-response.ts' const JSON_HEADERS = { ...corsHeaders, @@ -739,29 +740,8 @@ Deno.serve(async (req: Request) => { dispatchLeaseToken: reservation.dispatchLeaseToken, inviteContext: resolved.inviteContext, }) - const responseBody = { - event_type: input.eventType, - resource_id: input.resourceId, - duplicate: false, - status: result.status, - complete: result.complete, - attempted: result.attempted, - sent: result.delivered, - stale_removed: result.stale, - retryable_failed: result.retryableFailed, - permanent_failed: result.permanentFailed, - next_retry_at: result.nextRetryAt, - } - if (result.transientError) { - return jsonResponse( - { error: result.transientError, ...responseBody }, - result.transientStatus ?? 502, - ) - } - if (result.status === 'partial' || result.status === 'failed') { - return jsonResponse({ error: 'push_delivery_failed', ...responseBody }, 502) - } - return jsonResponse(responseBody, result.complete ? 200 : 202) + const presented = presentDispatchOutcome(actor.kind, input.eventType, input.resourceId, result) + return jsonResponse(presented.body, presented.status) } catch (error) { if (error instanceof PushContractError) { return jsonResponse({ error: error.code, message: error.code }, error.status)