fix(push): stop web push from posting to arbitrary endpoints
This commit is contained in:
parent
1afaea7214
commit
043ef579a8
7 changed files with 418 additions and 34 deletions
78
server/supabase/functions/_shared/webpush-endpoint-policy.ts
Normal file
78
server/supabase/functions/_shared/webpush-endpoint-policy.ts
Normal file
|
|
@ -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 }
|
||||||
|
}
|
||||||
159
server/supabase/functions/_shared/webpush-redteam-r1-19.test.ts
Normal file
159
server/supabase/functions/_shared/webpush-redteam-r1-19.test.ts
Normal file
|
|
@ -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<WebPushConfig> {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
@ -114,7 +114,7 @@ async function makeSubscription(): Promise<{ registrationId: string; subscriptio
|
||||||
const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', uaKeys.publicKey))
|
const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', uaKeys.publicKey))
|
||||||
const auth = crypto.getRandomValues(new Uint8Array(16))
|
const auth = crypto.getRandomValues(new Uint8Array(16))
|
||||||
const subscription: WebPushSubscription = {
|
const subscription: WebPushSubscription = {
|
||||||
endpoint: 'https://push.example.com/subscriptions/abc123',
|
endpoint: 'https://fcm.googleapis.com/fcm/send/abc123',
|
||||||
p256dh: b64url(publicRaw),
|
p256dh: b64url(publicRaw),
|
||||||
auth: b64url(auth),
|
auth: b64url(auth),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
// VAPID and encrypt per RFC 8291. No Google account or Firebase project needed.
|
// VAPID and encrypt per RFC 8291. No Google account or Firebase project needed.
|
||||||
|
|
||||||
import { PushContractError, type PushNotification } from './push-contract.ts'
|
import { PushContractError, type PushNotification } from './push-contract.ts'
|
||||||
|
import { evaluateWebPushEndpoint } from './webpush-endpoint-policy.ts'
|
||||||
|
|
||||||
export interface WebPushConfig {
|
export interface WebPushConfig {
|
||||||
/** base64url encoded uncompressed P-256 public key (65 bytes, 0x04 prefix). */
|
/** 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<string, unknown>)[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 {
|
export function parseWebPushSubscription(registrationId: string): WebPushSubscription {
|
||||||
if (typeof registrationId !== 'string' || registrationId.length < 20 || registrationId.length > 8192) {
|
if (typeof registrationId !== 'string' || registrationId.length < 20 || registrationId.length > 8192) {
|
||||||
throw new PushContractError('webpush_registration_invalid', 400, true)
|
throw new PushContractError('webpush_registration_invalid', 400, true)
|
||||||
|
|
@ -124,18 +136,13 @@ export function parseWebPushSubscription(registrationId: string): WebPushSubscri
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(registrationId) as Record<string, unknown>
|
const parsed = JSON.parse(registrationId) as Record<string, unknown>
|
||||||
const endpoint = typeof parsed.endpoint === 'string' ? parsed.endpoint : ''
|
const endpoint = typeof parsed.endpoint === 'string' ? parsed.endpoint : ''
|
||||||
const keys = parsed.keys
|
const p256dh = readSubscriptionKey(parsed.keys, 'p256dh')
|
||||||
const p256dh = keys && typeof keys === 'object' && typeof (keys as Record<string, unknown>).p256dh === 'string'
|
const auth = readSubscriptionKey(parsed.keys, 'auth')
|
||||||
? (keys as Record<string, string>).p256dh
|
const verdict = evaluateWebPushEndpoint(endpoint)
|
||||||
: ''
|
|
||||||
const auth = keys && typeof keys === 'object' && typeof (keys as Record<string, unknown>).auth === 'string'
|
|
||||||
? (keys as Record<string, string>).auth
|
|
||||||
: ''
|
|
||||||
const endpointUrl = new URL(endpoint)
|
|
||||||
const p256dhBytes = decodeBase64Url(p256dh)
|
const p256dhBytes = decodeBase64Url(p256dh)
|
||||||
const authBytes = decodeBase64Url(auth)
|
const authBytes = decodeBase64Url(auth)
|
||||||
if (
|
if (
|
||||||
endpointUrl.protocol !== 'https:'
|
!verdict.allowed
|
||||||
|| p256dhBytes.byteLength !== 65
|
|| p256dhBytes.byteLength !== 65
|
||||||
|| p256dhBytes[0] !== 0x04
|
|| p256dhBytes[0] !== 0x04
|
||||||
|| authBytes.byteLength < 16
|
|| authBytes.byteLength < 16
|
||||||
|
|
@ -143,7 +150,7 @@ export function parseWebPushSubscription(registrationId: string): WebPushSubscri
|
||||||
) {
|
) {
|
||||||
throw new Error('invalid subscription')
|
throw new Error('invalid subscription')
|
||||||
}
|
}
|
||||||
return { endpoint: endpointUrl.toString(), p256dh, auth }
|
return { endpoint: verdict.url.toString(), p256dh, auth }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PushContractError) throw error
|
if (error instanceof PushContractError) throw error
|
||||||
throw new PushContractError('webpush_registration_invalid', 400, true)
|
throw new PushContractError('webpush_registration_invalid', 400, true)
|
||||||
|
|
@ -289,6 +296,8 @@ export async function sendWebPushMessage(
|
||||||
TTL: '86400',
|
TTL: '86400',
|
||||||
},
|
},
|
||||||
body,
|
body,
|
||||||
|
// Never follow a redirect off the allowlisted push service host.
|
||||||
|
redirect: 'manual',
|
||||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -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> = {}): 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<string>()
|
||||||
|
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`)
|
||||||
|
}
|
||||||
|
})
|
||||||
74
server/supabase/functions/send-push/dispatch-response.ts
Normal file
74
server/supabase/functions/send-push/dispatch-response.ts
Normal file
|
|
@ -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<string, unknown>
|
||||||
|
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<string, unknown> = {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
import { readApnsConfig, sendApnsMessage } from '../_shared/apns.ts'
|
import { readApnsConfig, sendApnsMessage } from '../_shared/apns.ts'
|
||||||
import { readWebPushConfig, sendWebPushMessage } from '../_shared/webpush.ts'
|
import { readWebPushConfig, sendWebPushMessage } from '../_shared/webpush.ts'
|
||||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||||
|
import { presentDispatchOutcome } from './dispatch-response.ts'
|
||||||
|
|
||||||
const JSON_HEADERS = {
|
const JSON_HEADERS = {
|
||||||
...corsHeaders,
|
...corsHeaders,
|
||||||
|
|
@ -739,29 +740,8 @@ Deno.serve(async (req: Request) => {
|
||||||
dispatchLeaseToken: reservation.dispatchLeaseToken,
|
dispatchLeaseToken: reservation.dispatchLeaseToken,
|
||||||
inviteContext: resolved.inviteContext,
|
inviteContext: resolved.inviteContext,
|
||||||
})
|
})
|
||||||
const responseBody = {
|
const presented = presentDispatchOutcome(actor.kind, input.eventType, input.resourceId, result)
|
||||||
event_type: input.eventType,
|
return jsonResponse(presented.body, presented.status)
|
||||||
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)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PushContractError) {
|
if (error instanceof PushContractError) {
|
||||||
return jsonResponse({ error: error.code, message: error.code }, error.status)
|
return jsonResponse({ error: error.code, message: error.code }, error.status)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue