d3ro-voice/server/supabase/functions/_shared/webpush-endpoint-policy.ts

78 lines
3.2 KiB
TypeScript

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