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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue