fix(push): stop web push from posting to arbitrary endpoints

This commit is contained in:
Yun Chan 2026-09-28 00:53:48 +09:00
parent 1afaea7214
commit 043ef579a8
7 changed files with 418 additions and 34 deletions

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

View 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')
})

View file

@ -114,7 +114,7 @@ async function makeSubscription(): Promise<{ registrationId: string; subscriptio
const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', uaKeys.publicKey))
const auth = crypto.getRandomValues(new Uint8Array(16))
const subscription: WebPushSubscription = {
endpoint: 'https://push.example.com/subscriptions/abc123',
endpoint: 'https://fcm.googleapis.com/fcm/send/abc123',
p256dh: b64url(publicRaw),
auth: b64url(auth),
}

View file

@ -5,6 +5,7 @@
// VAPID and encrypt per RFC 8291. No Google account or Firebase project needed.
import { PushContractError, type PushNotification } from './push-contract.ts'
import { evaluateWebPushEndpoint } from './webpush-endpoint-policy.ts'
export interface WebPushConfig {
/** 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 {
if (typeof registrationId !== 'string' || registrationId.length < 20 || registrationId.length > 8192) {
throw new PushContractError('webpush_registration_invalid', 400, true)
@ -124,18 +136,13 @@ export function parseWebPushSubscription(registrationId: string): WebPushSubscri
try {
const parsed = JSON.parse(registrationId) as Record<string, unknown>
const endpoint = typeof parsed.endpoint === 'string' ? parsed.endpoint : ''
const keys = parsed.keys
const p256dh = keys && typeof keys === 'object' && typeof (keys as Record<string, unknown>).p256dh === 'string'
? (keys as Record<string, string>).p256dh
: ''
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 p256dh = readSubscriptionKey(parsed.keys, 'p256dh')
const auth = readSubscriptionKey(parsed.keys, 'auth')
const verdict = evaluateWebPushEndpoint(endpoint)
const p256dhBytes = decodeBase64Url(p256dh)
const authBytes = decodeBase64Url(auth)
if (
endpointUrl.protocol !== 'https:'
!verdict.allowed
|| p256dhBytes.byteLength !== 65
|| p256dhBytes[0] !== 0x04
|| authBytes.byteLength < 16
@ -143,7 +150,7 @@ export function parseWebPushSubscription(registrationId: string): WebPushSubscri
) {
throw new Error('invalid subscription')
}
return { endpoint: endpointUrl.toString(), p256dh, auth }
return { endpoint: verdict.url.toString(), p256dh, auth }
} catch (error) {
if (error instanceof PushContractError) throw error
throw new PushContractError('webpush_registration_invalid', 400, true)
@ -289,6 +296,8 @@ export async function sendWebPushMessage(
TTL: '86400',
},
body,
// Never follow a redirect off the allowlisted push service host.
redirect: 'manual',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
} catch {