Skip to content

Commit bef89b4

Browse files
Digidaiclaude
andcommitted
fix: P0 security and data integrity fixes for launch readiness
- Fix CHECK constraint on emails.status to include delivered/bounced/complained/delivery_delayed - Add Svix HMAC-SHA256 signature verification to Resend webhook endpoint - Add 5 new tests for webhook signature validation (valid/invalid/missing/expired) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b80e2ee commit bef89b4

3 files changed

Lines changed: 170 additions & 2 deletions

File tree

test/unit/delivery-status.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
import { describe, test, expect, mock, afterEach } from 'bun:test'
22

3+
/** Helper: generate a valid Svix signature for testing */
4+
async function signWebhook(secret: string, svixId: string, timestamp: string, body: string) {
5+
const secretBytes = Uint8Array.from(atob(secret.replace(/^whsec_/, '')), c => c.charCodeAt(0))
6+
const toSign = `${svixId}.${timestamp}.${body}`
7+
const key = await crypto.subtle.importKey(
8+
'raw', secretBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
9+
)
10+
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(toSign))
11+
return `v1,${btoa(String.fromCharCode(...new Uint8Array(sig)))}`
12+
}
13+
14+
const TEST_SECRET = 'whsec_' + btoa('test-secret-key-for-unit-tests!')
15+
316
describe('Delivery Status Handler', () => {
417
afterEach(() => { mock.restore() })
518

@@ -65,4 +78,109 @@ describe('Delivery Status Handler', () => {
6578
const body = await res.json() as { ok: boolean }
6679
expect(body.ok).toBe(true)
6780
})
81+
82+
test('rejects webhook with invalid signature when secret is configured', async () => {
83+
const { handleResendWebhook } = await import('../../worker/src/handlers/delivery-status')
84+
85+
const bodyStr = JSON.stringify({ type: 'email.sent', created_at: '2026-04-04', data: { email_id: 'abc' } })
86+
const request = new Request('http://localhost/api/resend-webhook', {
87+
method: 'POST',
88+
headers: {
89+
'Content-Type': 'application/json',
90+
'svix-id': 'msg_test123',
91+
'svix-timestamp': String(Math.floor(Date.now() / 1000)),
92+
'svix-signature': 'v1,invalidsignature',
93+
},
94+
body: bodyStr,
95+
})
96+
const env = { RESEND_WEBHOOK_SECRET: TEST_SECRET, DB: {} } as any
97+
const ctx = { waitUntil: () => {} } as any
98+
99+
const res = await handleResendWebhook(request, env, ctx)
100+
expect(res.status).toBe(401)
101+
})
102+
103+
test('rejects webhook with missing signature headers when secret is configured', async () => {
104+
const { handleResendWebhook } = await import('../../worker/src/handlers/delivery-status')
105+
106+
const request = new Request('http://localhost/api/resend-webhook', {
107+
method: 'POST',
108+
headers: { 'Content-Type': 'application/json' },
109+
body: JSON.stringify({ type: 'email.sent', created_at: '2026-04-04', data: { email_id: 'abc' } }),
110+
})
111+
const env = { RESEND_WEBHOOK_SECRET: TEST_SECRET, DB: {} } as any
112+
const ctx = { waitUntil: () => {} } as any
113+
114+
const res = await handleResendWebhook(request, env, ctx)
115+
expect(res.status).toBe(401)
116+
})
117+
118+
test('rejects webhook with expired timestamp', async () => {
119+
const { handleResendWebhook } = await import('../../worker/src/handlers/delivery-status')
120+
121+
const bodyStr = JSON.stringify({ type: 'email.sent', created_at: '2026-04-04', data: { email_id: 'abc' } })
122+
const expiredTs = String(Math.floor(Date.now() / 1000) - 600) // 10 min ago
123+
const sig = await signWebhook(TEST_SECRET, 'msg_test123', expiredTs, bodyStr)
124+
125+
const request = new Request('http://localhost/api/resend-webhook', {
126+
method: 'POST',
127+
headers: {
128+
'Content-Type': 'application/json',
129+
'svix-id': 'msg_test123',
130+
'svix-timestamp': expiredTs,
131+
'svix-signature': sig,
132+
},
133+
body: bodyStr,
134+
})
135+
const env = { RESEND_WEBHOOK_SECRET: TEST_SECRET, DB: {} } as any
136+
const ctx = { waitUntil: () => {} } as any
137+
138+
const res = await handleResendWebhook(request, env, ctx)
139+
expect(res.status).toBe(401)
140+
})
141+
142+
test('accepts webhook with valid signature', async () => {
143+
const { handleResendWebhook } = await import('../../worker/src/handlers/delivery-status')
144+
145+
const bodyStr = JSON.stringify({ type: 'email.unknown_event', created_at: '2026-04-04', data: { email_id: 'abc' } })
146+
const ts = String(Math.floor(Date.now() / 1000))
147+
const svixId = 'msg_valid123'
148+
const sig = await signWebhook(TEST_SECRET, svixId, ts, bodyStr)
149+
150+
const request = new Request('http://localhost/api/resend-webhook', {
151+
method: 'POST',
152+
headers: {
153+
'Content-Type': 'application/json',
154+
'svix-id': svixId,
155+
'svix-timestamp': ts,
156+
'svix-signature': sig,
157+
},
158+
body: bodyStr,
159+
})
160+
const env = {
161+
RESEND_WEBHOOK_SECRET: TEST_SECRET,
162+
DB: { prepare: () => ({ bind: () => ({ first: async () => null, run: async () => ({}) }) }) },
163+
} as any
164+
const ctx = { waitUntil: () => {} } as any
165+
166+
const res = await handleResendWebhook(request, env, ctx)
167+
// Unknown event type → acknowledged with 200
168+
expect(res.status).toBe(200)
169+
})
170+
171+
test('skips signature check when RESEND_WEBHOOK_SECRET is not set', async () => {
172+
const { handleResendWebhook } = await import('../../worker/src/handlers/delivery-status')
173+
174+
const request = new Request('http://localhost/api/resend-webhook', {
175+
method: 'POST',
176+
headers: { 'Content-Type': 'application/json' },
177+
body: JSON.stringify({ type: 'email.unknown', created_at: '2026-04-04', data: { email_id: 'abc' } }),
178+
})
179+
// No RESEND_WEBHOOK_SECRET in env
180+
const env = { DB: { prepare: () => ({ bind: () => ({ first: async () => null, run: async () => ({}) }) }) } } as any
181+
const ctx = { waitUntil: () => {} } as any
182+
183+
const res = await handleResendWebhook(request, env, ctx)
184+
expect(res.status).toBe(200)
185+
})
68186
})

worker/schema.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS emails (
2020
attachment_search_text TEXT DEFAULT '',
2121
raw_storage_key TEXT,
2222
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
23-
status TEXT DEFAULT 'received' CHECK (status IN ('received', 'sent', 'failed', 'queued')),
23+
status TEXT DEFAULT 'received' CHECK (status IN ('received', 'sent', 'failed', 'queued', 'delivered', 'bounced', 'complained', 'delivery_delayed')),
2424
received_at TEXT NOT NULL,
2525
created_at TEXT NOT NULL
2626
);

worker/src/handlers/delivery-status.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,45 @@ import type { Env } from '../types'
22
import { recordEvent } from './events'
33
import { fireWebhookWithRetry } from './webhook'
44

5+
/**
6+
* Verify Resend webhook signature (Svix).
7+
* Resend signs webhooks with svix-id, svix-timestamp, svix-signature headers.
8+
* See: https://resend.com/docs/dashboard/webhooks/introduction
9+
*/
10+
async function verifyResendSignature(
11+
request: Request,
12+
rawBody: string,
13+
secret: string,
14+
): Promise<boolean> {
15+
const svixId = request.headers.get('svix-id')
16+
const svixTimestamp = request.headers.get('svix-timestamp')
17+
const svixSignature = request.headers.get('svix-signature')
18+
19+
if (!svixId || !svixTimestamp || !svixSignature) return false
20+
21+
// Reject timestamps older than 5 minutes
22+
const ts = parseInt(svixTimestamp, 10)
23+
const now = Math.floor(Date.now() / 1000)
24+
if (Math.abs(now - ts) > 300) return false
25+
26+
// Svix secret is base64-encoded after "whsec_" prefix
27+
const secretBytes = Uint8Array.from(atob(secret.replace(/^whsec_/, '')), c => c.charCodeAt(0))
28+
29+
const toSign = `${svixId}.${svixTimestamp}.${rawBody}`
30+
const key = await crypto.subtle.importKey(
31+
'raw', secretBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
32+
)
33+
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(toSign))
34+
const expected = btoa(String.fromCharCode(...new Uint8Array(sig)))
35+
36+
// svix-signature may contain multiple signatures separated by spaces (v1,xxx v1,yyy)
37+
const signatures = svixSignature.split(' ')
38+
return signatures.some(s => {
39+
const val = s.split(',')[1]
40+
return val === expected
41+
})
42+
}
43+
544
/**
645
* Resend webhook callback handler.
746
* POST /api/resend-webhook — receives delivery status updates from Resend.
@@ -10,12 +49,23 @@ import { fireWebhookWithRetry } from './webhook'
1049
* email.complained, email.delivery_delayed
1150
*
1251
* Updates the email status in D1 and fires user webhooks + SSE events.
52+
* Signature verified via RESEND_WEBHOOK_SECRET (Svix HMAC-SHA256).
1353
*/
1454
export async function handleResendWebhook(
1555
request: Request,
1656
env: Env,
1757
ctx: ExecutionContext,
1858
): Promise<Response> {
59+
const rawBody = await request.text()
60+
61+
// Verify webhook signature if secret is configured
62+
if (env.RESEND_WEBHOOK_SECRET) {
63+
const valid = await verifyResendSignature(request, rawBody, env.RESEND_WEBHOOK_SECRET)
64+
if (!valid) {
65+
return Response.json({ error: 'Invalid webhook signature' }, { status: 401 })
66+
}
67+
}
68+
1969
let body: {
2070
type: string
2171
created_at: string
@@ -29,7 +79,7 @@ export async function handleResendWebhook(
2979
}
3080

3181
try {
32-
body = await request.json()
82+
body = JSON.parse(rawBody)
3383
} catch {
3484
return Response.json({ error: 'Invalid JSON' }, { status: 400 })
3585
}

0 commit comments

Comments
 (0)