78 lines
3.2 KiB
TypeScript
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 }
|
|
}
|