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
159
server/supabase/functions/_shared/webpush-redteam-r1-19.test.ts
Normal file
159
server/supabase/functions/_shared/webpush-redteam-r1-19.test.ts
Normal 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')
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue