// 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 } }