Skip to content

Commit efc8106

Browse files
committed
improvement(webhooks): share one Standard Webhooks verifier across providers
1 parent 064f10d commit efc8106

13 files changed

Lines changed: 416 additions & 380 deletions

File tree

‎apps/sim/app/api/webhooks/agentmail/route.test.ts‎

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,8 @@ const { mockVerify, mockExecuteInboxTask } = vi.hoisted(() => ({
1515
mockExecuteInboxTask: vi.fn(),
1616
}))
1717

18-
vi.mock('svix', () => ({
19-
Webhook: class {
20-
private readonly secret: string
21-
constructor(secret: string) {
22-
this.secret = secret
23-
}
24-
verify(payload: string, headers: Record<string, string>) {
25-
return mockVerify(this.secret, payload, headers)
26-
}
27-
},
18+
vi.mock('@sim/security/standard-webhooks', () => ({
19+
verifyStandardWebhook: mockVerify,
2820
}))
2921

3022
vi.mock('@/lib/core/admission/gate', () => admissionGateMock)
@@ -92,9 +84,7 @@ describe('POST /api/webhooks/agentmail', () => {
9284

9385
mockTryAdmit.mockReturnValue({ release: mockRelease })
9486
mockExecuteInboxTask.mockResolvedValue(undefined)
95-
mockVerify.mockImplementation(() => {
96-
throw new Error('signature mismatch')
97-
})
87+
mockVerify.mockReturnValue({ valid: false, reason: 'no_matching_signature' })
9888
})
9989

10090
it('checks the signature against only the secret the payload routes to', async () => {
@@ -106,12 +96,14 @@ describe('POST /api/webhooks/agentmail', () => {
10696
expect(mockEq).toHaveBeenCalledWith(schemaMock.workspace.inboxProviderId, TARGET_INBOX_ID)
10797
expect(dbChainMockFns.limit).toHaveBeenCalledWith(1)
10898
expect(mockVerify).toHaveBeenCalledTimes(1)
109-
expect(mockVerify).toHaveBeenCalledWith('whsec_b', expect.any(String), expect.any(Object))
99+
expect(mockVerify).toHaveBeenCalledWith(
100+
expect.objectContaining({ secret: 'whsec_b', headerPrefix: 'svix' })
101+
)
110102
})
111103

112104
it('rejects a payload naming an inbox no workspace owns, without hashing it', async () => {
113105
queueTableRows(schemaMock.workspace, [])
114-
mockVerify.mockReturnValue(undefined)
106+
mockVerify.mockReturnValue({ valid: true })
115107

116108
const response = await POST(
117109
webhookRequest(envelope({ inbox_id: 'agent-unknown@agentmail.to' }))

‎apps/sim/app/api/webhooks/agentmail/route.ts‎

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ import {
88
workspace,
99
} from '@sim/db'
1010
import { createLogger } from '@sim/logger'
11+
import { verifyStandardWebhook } from '@sim/security/standard-webhooks'
1112
import { getErrorMessage } from '@sim/utils/errors'
1213
import { generateId } from '@sim/utils/id'
1314
import { tasks } from '@trigger.dev/sdk'
1415
import { and, eq, gt, ne, sql } from 'drizzle-orm'
1516
import { NextResponse } from 'next/server'
16-
import { Webhook } from 'svix'
1717
import {
1818
agentMailEnvelopeSchema,
1919
agentMailMessageSchema,
@@ -41,22 +41,8 @@ const MAX_EMAILS_PER_HOUR = 20
4141

4242
const AGENTMAIL_BODY_LABEL = 'AgentMail webhook body'
4343

44-
/** Svix throws on a bad signature, stale timestamp, or malformed header. */
45-
function verifiesAgainst(
46-
secret: string,
47-
rawBody: string,
48-
headers: Record<string, string>
49-
): boolean {
50-
try {
51-
new Webhook(secret).verify(rawBody, headers)
52-
return true
53-
} catch {
54-
return false
55-
}
56-
}
57-
5844
/**
59-
* Bound the unauthenticated AgentMail webhook body before buffering it for Svix
45+
* Bound the unauthenticated AgentMail webhook body before buffering it for
6046
* signature verification, so an oversized payload cannot exhaust pod memory.
6147
*/
6248
async function readAgentMailBody(req: Request): Promise<string> {
@@ -106,7 +92,7 @@ export const POST = withRouteHandler(async (req: Request) => {
10692
}
10793

10894
/**
109-
* Route to one tenant before hashing: svix HMACs the whole body synchronously,
95+
* Route to one tenant before hashing: verification HMACs the whole body synchronously,
11096
* so checking every workspace secret would let one unauthenticated request cost
11197
* a hash per tenant. `inbox_id` is attacker-controlled but only selects which
11298
* secret to check — verification still binds the payload's inbox to the workspace.
@@ -129,7 +115,15 @@ export const POST = withRouteHandler(async (req: Request) => {
129115
.where(eq(workspace.inboxProviderId, inboxId))
130116
.limit(1)
131117

132-
if (!result || !verifiesAgainst(result.webhookSecret, rawBody, headersResult.data)) {
118+
if (
119+
!result ||
120+
!verifyStandardWebhook({
121+
secret: result.webhookSecret,
122+
rawBody,
123+
headers: req.headers,
124+
headerPrefix: 'svix',
125+
}).valid
126+
) {
133127
logger.warn('Webhook signature verification failed', { payloadInboxId: inboxId })
134128
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
135129
}

‎apps/sim/app/api/webhooks/agentmail/thread-scope.integration.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/
44
* `In-Reply-To` header — a value every recipient of the agent's reply holds. Against
55
* real PostgreSQL, a reply delivered to one workspace's inbox must never reach a chat,
66
* task, or transcript owned by another workspace, while a reply inside the workspace
7-
* still continues its thread. The webhook is signed with the real Svix scheme.
7+
* still continues its thread. The webhook is signed per the Standard Webhooks spec.
88
*/
99
import { authBanMock } from '@sim/testing/mocks/auth-ban.mock'
1010
import {
@@ -40,6 +40,7 @@ vi.mock('@/lib/mothership/inbox/executor', () => ({
4040
executeInboxTask: mockExecuteInboxTask,
4141
}))
4242

43+
import { createHmac } from 'node:crypto'
4344
import { db } from '@sim/db'
4445
import {
4546
copilotChats,
@@ -53,7 +54,6 @@ import {
5354
import { generateId } from '@sim/utils/id'
5455
import { and, eq, inArray } from 'drizzle-orm'
5556
import { NextRequest } from 'next/server'
56-
import { Webhook } from 'svix'
5757
import { persistCopilotChatTurn } from '@/lib/mothership/chat/messages-store'
5858
import { buildPersistedUserMessage } from '@/lib/mothership/chat/persisted-message'
5959
import { POST } from '@/app/api/webhooks/agentmail/route'
@@ -142,15 +142,19 @@ async function deliver(
142142
},
143143
})
144144
const svixId = `msg_${generateId()}`
145-
const timestamp = new Date()
145+
const timestamp = String(Math.floor(Date.now() / 1000))
146+
const key = Buffer.from(ws.secret.slice('whsec_'.length), 'base64')
147+
const signature = createHmac('sha256', key)
148+
.update(`${svixId}.${timestamp}.${body}`)
149+
.digest('base64')
146150
return POST(
147151
new NextRequest('http://localhost:3000/api/webhooks/agentmail', {
148152
method: 'POST',
149153
headers: {
150154
'content-type': 'application/json',
151155
'svix-id': svixId,
152-
'svix-timestamp': String(Math.floor(timestamp.getTime() / 1000)),
153-
'svix-signature': new Webhook(ws.secret).sign(svixId, timestamp, body),
156+
'svix-timestamp': timestamp,
157+
'svix-signature': `v1,${signature}`,
154158
},
155159
body,
156160
}),

‎apps/sim/lib/webhooks/providers/clerk.ts‎

Lines changed: 15 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { createLogger } from '@sim/logger'
2-
import { safeCompare } from '@sim/security/compare'
3-
import { hmacSha256Base64 } from '@sim/security/hmac'
2+
import { verifyStandardWebhook } from '@sim/security/standard-webhooks'
43
import { NextResponse } from 'next/server'
54
import type {
65
AuthContext,
@@ -12,46 +11,6 @@ import type {
1211

1312
const logger = createLogger('WebhookProvider:Clerk')
1413

15-
/**
16-
* Verify a Clerk webhook signature using the Svix signing scheme.
17-
* Clerk uses Svix under the hood: HMAC-SHA256 of `${svix-id}.${svix-timestamp}.${body}`
18-
* signed with the base64-decoded `whsec_...` secret, compared against the
19-
* space-delimited, versioned (`v1,<sig>`) `svix-signature` header.
20-
*/
21-
function verifySvixSignature(
22-
secret: string,
23-
msgId: string,
24-
timestamp: string,
25-
signatures: string,
26-
rawBody: string
27-
): boolean {
28-
try {
29-
const ts = Number.parseInt(timestamp, 10)
30-
const now = Math.floor(Date.now() / 1000)
31-
if (Number.isNaN(ts) || Math.abs(now - ts) > 5 * 60) {
32-
return false
33-
}
34-
35-
const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
36-
const toSign = `${msgId}.${timestamp}.${rawBody}`
37-
const expectedSignature = hmacSha256Base64(toSign, secretBytes)
38-
39-
const providedSignatures = signatures.split(' ')
40-
for (const versionedSig of providedSignatures) {
41-
const parts = versionedSig.split(',')
42-
if (parts.length !== 2) continue
43-
const sig = parts[1]
44-
if (safeCompare(sig, expectedSignature)) {
45-
return true
46-
}
47-
}
48-
return false
49-
} catch (error) {
50-
logger.error('Error verifying Clerk Svix signature:', error)
51-
return false
52-
}
53-
}
54-
5514
export const clerkHandler: WebhookProviderHandler = {
5615
async verifyAuth({
5716
request,
@@ -65,17 +24,20 @@ export const clerkHandler: WebhookProviderHandler = {
6524
return new NextResponse('Unauthorized - Clerk signing secret is required', { status: 401 })
6625
}
6726

68-
const svixId = request.headers.get('svix-id')
69-
const svixTimestamp = request.headers.get('svix-timestamp')
70-
const svixSignature = request.headers.get('svix-signature')
71-
72-
if (!svixId || !svixTimestamp || !svixSignature) {
73-
logger.warn(`[${requestId}] Clerk webhook missing Svix signature headers`)
74-
return new NextResponse('Unauthorized - Missing Clerk signature headers', { status: 401 })
75-
}
76-
77-
if (!verifySvixSignature(signingSecret, svixId, svixTimestamp, svixSignature, rawBody)) {
78-
logger.warn(`[${requestId}] Clerk Svix signature verification failed`)
27+
const verification = verifyStandardWebhook({
28+
secret: signingSecret,
29+
rawBody,
30+
headers: request.headers,
31+
headerPrefix: 'svix',
32+
})
33+
if (!verification.valid) {
34+
if (verification.reason === 'missing_headers') {
35+
logger.warn(`[${requestId}] Clerk webhook missing Svix signature headers`)
36+
return new NextResponse('Unauthorized - Missing Clerk signature headers', { status: 401 })
37+
}
38+
logger.warn(`[${requestId}] Clerk Svix signature verification failed`, {
39+
reason: verification.reason,
40+
})
7941
return new NextResponse('Unauthorized - Invalid Clerk signature', { status: 401 })
8042
}
8143

‎apps/sim/lib/webhooks/providers/granola.ts‎

Lines changed: 14 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { createLogger } from '@sim/logger'
2-
import { safeCompare } from '@sim/security/compare'
3-
import { hmacSha256Base64 } from '@sim/security/hmac'
2+
import { verifyStandardWebhook } from '@sim/security/standard-webhooks'
43
import { toRecordOrNull } from '@sim/utils/object'
54
import { NextResponse } from 'next/server'
65
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
@@ -20,51 +19,9 @@ const logger = createLogger('WebhookProvider:Granola')
2019

2120
const GRANOLA_WEBHOOK_ENDPOINTS_URL = 'https://public-api.granola.ai/v1/webhook-endpoints'
2221

23-
/** Granola advises rejecting deliveries whose timestamp is more than a few minutes old. */
24-
const GRANOLA_WEBHOOK_TIMESTAMP_SKEW_SECONDS = 5 * 60
25-
2622
/** Scopes used when the trigger leaves the field blank. */
2723
const DEFAULT_GRANOLA_SCOPES = ['personal', 'public']
2824

29-
/**
30-
* Verify a Granola webhook signature using the Standard Webhooks scheme:
31-
* HMAC-SHA256 of `${webhook-id}.${webhook-timestamp}.${body}` keyed with the
32-
* base64-decoded signing secret (after the `whsec_` prefix), compared against
33-
* the `webhook-signature` header which may carry one or more space-separated
34-
* `v1,<base64sig>` entries.
35-
* @see https://docs.granola.ai/webhooks
36-
*/
37-
function verifyGranolaSignature(
38-
secret: string,
39-
msgId: string,
40-
timestamp: string,
41-
signatures: string,
42-
rawBody: string
43-
): boolean {
44-
try {
45-
const ts = Number.parseInt(timestamp, 10)
46-
const now = Math.floor(Date.now() / 1000)
47-
if (Number.isNaN(ts) || Math.abs(now - ts) > GRANOLA_WEBHOOK_TIMESTAMP_SKEW_SECONDS) {
48-
return false
49-
}
50-
51-
const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
52-
const expectedSignature = hmacSha256Base64(`${msgId}.${timestamp}.${rawBody}`, secretBytes)
53-
54-
for (const versionedSig of signatures.split(' ')) {
55-
const parts = versionedSig.split(',')
56-
if (parts.length !== 2) continue
57-
if (parts[0] === 'v1' && safeCompare(parts[1], expectedSignature)) {
58-
return true
59-
}
60-
}
61-
return false
62-
} catch (error) {
63-
logger.error('Error verifying Granola webhook signature:', error)
64-
return false
65-
}
66-
}
67-
6825
/**
6926
* Parse a comma-separated or JSON-array trigger field into a string list.
7027
* Trigger fields are free text, so both forms are accepted.
@@ -187,19 +144,19 @@ export const granolaHandler: WebhookProviderHandler = {
187144
return new NextResponse('Unauthorized - Granola signing secret is required', { status: 401 })
188145
}
189146

190-
const webhookId = request.headers.get('webhook-id')
191-
const webhookTimestamp = request.headers.get('webhook-timestamp')
192-
const webhookSignature = request.headers.get('webhook-signature')
193-
194-
if (!webhookId || !webhookTimestamp || !webhookSignature) {
195-
logger.warn(`[${requestId}] Granola webhook missing Standard Webhooks signature headers`)
196-
return new NextResponse('Unauthorized - Missing Granola signature headers', { status: 401 })
197-
}
198-
199-
if (
200-
!verifyGranolaSignature(signingSecret, webhookId, webhookTimestamp, webhookSignature, rawBody)
201-
) {
202-
logger.warn(`[${requestId}] Granola signature verification failed`)
147+
const verification = verifyStandardWebhook({
148+
secret: signingSecret,
149+
rawBody,
150+
headers: request.headers,
151+
})
152+
if (!verification.valid) {
153+
if (verification.reason === 'missing_headers') {
154+
logger.warn(`[${requestId}] Granola webhook missing Standard Webhooks signature headers`)
155+
return new NextResponse('Unauthorized - Missing Granola signature headers', { status: 401 })
156+
}
157+
logger.warn(`[${requestId}] Granola signature verification failed`, {
158+
reason: verification.reason,
159+
})
203160
return new NextResponse('Unauthorized - Invalid Granola signature', { status: 401 })
204161
}
205162

0 commit comments

Comments
 (0)