Skip to content

Commit 62c0e7e

Browse files
committed
fix(triggers): capture Resend signing secret and add Svix webhook verification
1 parent 5925c87 commit 62c0e7e

File tree

2 files changed

+76
-1
lines changed

2 files changed

+76
-1
lines changed

apps/sim/lib/webhooks/provider-subscriptions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const SYSTEM_MANAGED_FIELDS = new Set([
2323
'eventTypes',
2424
'webhookTag',
2525
'webhookSecret',
26+
'signingSecret',
27+
'secretToken',
2628
'historyId',
2729
'lastCheckedTimestamp',
2830
'setupCompleted',

apps/sim/lib/webhooks/providers/resend.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import crypto from 'node:crypto'
12
import { createLogger } from '@sim/logger'
3+
import { NextResponse } from 'next/server'
4+
import { safeCompare } from '@/lib/core/security/encryption'
25
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils'
36
import type {
7+
AuthContext,
48
DeleteSubscriptionContext,
59
FormatInputContext,
610
FormatInputResult,
@@ -31,7 +35,71 @@ const ALL_RESEND_EVENTS = [
3135
'domain.deleted',
3236
]
3337

38+
/**
39+
* Verify a Resend webhook signature using the Svix signing scheme.
40+
* Resend uses Svix under the hood: HMAC-SHA256 of `${svix-id}.${svix-timestamp}.${body}`
41+
* signed with the base64-decoded `whsec_...` secret.
42+
*/
43+
function verifySvixSignature(
44+
secret: string,
45+
msgId: string,
46+
timestamp: string,
47+
signatures: string,
48+
rawBody: string
49+
): boolean {
50+
try {
51+
const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
52+
const toSign = `${msgId}.${timestamp}.${rawBody}`
53+
const expectedSignature = crypto
54+
.createHmac('sha256', secretBytes)
55+
.update(toSign, 'utf8')
56+
.digest('base64')
57+
58+
const providedSignatures = signatures.split(' ')
59+
for (const versionedSig of providedSignatures) {
60+
const parts = versionedSig.split(',')
61+
if (parts.length !== 2) continue
62+
const sig = parts[1]
63+
if (safeCompare(sig, expectedSignature)) {
64+
return true
65+
}
66+
}
67+
return false
68+
} catch (error) {
69+
logger.error('Error verifying Resend Svix signature:', error)
70+
return false
71+
}
72+
}
73+
3474
export const resendHandler: WebhookProviderHandler = {
75+
async verifyAuth({
76+
request,
77+
rawBody,
78+
requestId,
79+
providerConfig,
80+
}: AuthContext): Promise<NextResponse | null> {
81+
const signingSecret = providerConfig.signingSecret as string | undefined
82+
if (!signingSecret) {
83+
return null
84+
}
85+
86+
const svixId = request.headers.get('svix-id')
87+
const svixTimestamp = request.headers.get('svix-timestamp')
88+
const svixSignature = request.headers.get('svix-signature')
89+
90+
if (!svixId || !svixTimestamp || !svixSignature) {
91+
logger.warn(`[${requestId}] Resend webhook missing Svix signature headers`)
92+
return new NextResponse('Unauthorized - Missing Resend signature headers', { status: 401 })
93+
}
94+
95+
if (!verifySvixSignature(signingSecret, svixId, svixTimestamp, svixSignature, rawBody)) {
96+
logger.warn(`[${requestId}] Resend Svix signature verification failed`)
97+
return new NextResponse('Unauthorized - Invalid Resend signature', { status: 401 })
98+
}
99+
100+
return null
101+
},
102+
35103
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
36104
const payload = body as Record<string, unknown>
37105
const data = payload.data as Record<string, unknown> | undefined
@@ -134,7 +202,12 @@ export const resendHandler: WebhookProviderHandler = {
134202
}
135203
)
136204

137-
return { providerConfigUpdates: { externalId: responseBody.id } }
205+
return {
206+
providerConfigUpdates: {
207+
externalId: responseBody.id,
208+
signingSecret: responseBody.signing_secret,
209+
},
210+
}
138211
} catch (error: unknown) {
139212
const err = error as Error
140213
logger.error(

0 commit comments

Comments
 (0)