Skip to content

Commit e9618d9

Browse files
committed
fix(webhooks): harden Vercel and Greenhouse trigger handlers
Require Vercel signing secret and validate x-vercel-signature; add matchEvent with dynamic import, delivery idempotency, strict createSubscription trigger IDs, and formatInput aligned to string IDs. Greenhouse: dynamic import in matchEvent, strict unknown trigger IDs, Greenhouse-Event-ID idempotency header, body fallback keys, clearer optional secret copy. Update generic trigger wording and add tests.
1 parent 0ddc769 commit e9618d9

File tree

11 files changed

+363
-46
lines changed

11 files changed

+363
-46
lines changed

apps/sim/lib/core/idempotency/service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,8 @@ export class IdempotencyService {
421421
normalizedHeaders?.['x-event-id'] ||
422422
normalizedHeaders?.['x-teams-notification-id'] ||
423423
normalizedHeaders?.['svix-id'] ||
424-
normalizedHeaders?.['linear-delivery']
424+
normalizedHeaders?.['linear-delivery'] ||
425+
normalizedHeaders?.['greenhouse-event-id']
425426

426427
if (webhookIdHeader) {
427428
return `${webhookId}:${webhookIdHeader}`
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it, vi } from 'vitest'
5+
import { IdempotencyService } from '@/lib/core/idempotency/service'
6+
7+
vi.mock('@/lib/core/utils/uuid', () => ({
8+
generateId: vi.fn(() => 'fallback-uuid'),
9+
}))
10+
11+
describe('IdempotencyService.createWebhookIdempotencyKey', () => {
12+
it('uses Greenhouse-Event-ID when present', () => {
13+
const key = IdempotencyService.createWebhookIdempotencyKey(
14+
'wh_1',
15+
{ 'greenhouse-event-id': 'evt-gh-99' },
16+
{},
17+
'greenhouse'
18+
)
19+
expect(key).toBe('wh_1:evt-gh-99')
20+
})
21+
})

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import type {
88
WebhookProviderHandler,
99
} from '@/lib/webhooks/providers/types'
1010
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
11-
import { isGreenhouseEventMatch } from '@/triggers/greenhouse/utils'
1211

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

@@ -58,6 +57,7 @@ export const greenhouseHandler: WebhookProviderHandler = {
5857
const action = b.action as string | undefined
5958

6059
if (triggerId && triggerId !== 'greenhouse_webhook') {
60+
const { isGreenhouseEventMatch } = await import('@/triggers/greenhouse/utils')
6161
if (!isGreenhouseEventMatch(triggerId, action || '')) {
6262
logger.debug(
6363
`[${requestId}] Greenhouse event mismatch for trigger ${triggerId}. Action: ${action}. Skipping execution.`,
@@ -75,4 +75,35 @@ export const greenhouseHandler: WebhookProviderHandler = {
7575

7676
return true
7777
},
78+
79+
/**
80+
* Fallback when Greenhouse-Event-ID is not available on headers (see idempotency service).
81+
* Prefer stable resource keys; offer events include version for new versions.
82+
*/
83+
extractIdempotencyId(body: unknown) {
84+
const b = body as Record<string, unknown>
85+
const action = typeof b.action === 'string' ? b.action : ''
86+
const payload = (b.payload || {}) as Record<string, unknown>
87+
88+
const application = (payload.application || {}) as Record<string, unknown>
89+
const appId = application.id
90+
if (appId !== undefined && appId !== null && appId !== '') {
91+
return `greenhouse:${action}:application:${String(appId)}`
92+
}
93+
94+
const offerId = payload.id
95+
const offerVersion = payload.version
96+
if (offerId !== undefined && offerId !== null && offerId !== '') {
97+
const v = offerVersion !== undefined && offerVersion !== null ? String(offerVersion) : '0'
98+
return `greenhouse:${action}:offer:${String(offerId)}:${v}`
99+
}
100+
101+
const job = (payload.job || {}) as Record<string, unknown>
102+
const jobId = job.id
103+
if (jobId !== undefined && jobId !== null && jobId !== '') {
104+
return `greenhouse:${action}:job:${String(jobId)}`
105+
}
106+
107+
return null
108+
},
78109
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import crypto from 'crypto'
5+
import { createMockRequest } from '@sim/testing'
6+
import { describe, expect, it, vi } from 'vitest'
7+
import { vercelHandler } from '@/lib/webhooks/providers/vercel'
8+
9+
vi.mock('@sim/logger', () => ({
10+
createLogger: vi.fn().mockReturnValue({
11+
info: vi.fn(),
12+
warn: vi.fn(),
13+
error: vi.fn(),
14+
debug: vi.fn(),
15+
}),
16+
}))
17+
18+
describe('vercelHandler', () => {
19+
describe('verifyAuth', () => {
20+
const secret = 'test-signing-secret'
21+
const rawBody = JSON.stringify({ type: 'deployment.created', id: 'del_1' })
22+
const signature = crypto.createHmac('sha1', secret).update(rawBody, 'utf8').digest('hex')
23+
24+
it('returns 401 when webhookSecret is missing', async () => {
25+
const request = createMockRequest('POST', JSON.parse(rawBody), {
26+
'x-vercel-signature': signature,
27+
})
28+
const res = await vercelHandler.verifyAuth!({
29+
request: request as any,
30+
rawBody,
31+
requestId: 'r1',
32+
providerConfig: {},
33+
webhook: {},
34+
workflow: {},
35+
})
36+
expect(res?.status).toBe(401)
37+
})
38+
39+
it('returns 401 when signature header is missing', async () => {
40+
const request = createMockRequest('POST', JSON.parse(rawBody), {})
41+
const res = await vercelHandler.verifyAuth!({
42+
request: request as any,
43+
rawBody,
44+
requestId: 'r1',
45+
providerConfig: { webhookSecret: secret },
46+
webhook: {},
47+
workflow: {},
48+
})
49+
expect(res?.status).toBe(401)
50+
})
51+
52+
it('returns null when signature is valid', async () => {
53+
const request = createMockRequest('POST', JSON.parse(rawBody), {
54+
'x-vercel-signature': signature,
55+
})
56+
const res = await vercelHandler.verifyAuth!({
57+
request: request as any,
58+
rawBody,
59+
requestId: 'r1',
60+
providerConfig: { webhookSecret: secret },
61+
webhook: {},
62+
workflow: {},
63+
})
64+
expect(res).toBeNull()
65+
})
66+
})
67+
68+
describe('extractIdempotencyId', () => {
69+
it('uses top-level delivery id from Vercel payload', () => {
70+
expect(vercelHandler.extractIdempotencyId!({ id: 'abc123' })).toBe('vercel:abc123')
71+
expect(vercelHandler.extractIdempotencyId!({})).toBeNull()
72+
})
73+
})
74+
})

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

Lines changed: 138 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,79 @@
11
import crypto from 'crypto'
22
import { createLogger } from '@sim/logger'
3+
import { NextResponse } from 'next/server'
34
import { safeCompare } from '@/lib/core/security/encryption'
45
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils'
56
import type {
7+
AuthContext,
68
DeleteSubscriptionContext,
9+
EventMatchContext,
710
FormatInputContext,
811
FormatInputResult,
912
SubscriptionContext,
1013
SubscriptionResult,
1114
WebhookProviderHandler,
1215
} from '@/lib/webhooks/providers/types'
13-
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
1416

1517
const logger = createLogger('WebhookProvider:Vercel')
1618

19+
function verifyVercelSignature(secret: string, signature: string, rawBody: string): boolean {
20+
const hash = crypto.createHmac('sha1', secret).update(rawBody, 'utf8').digest('hex')
21+
return safeCompare(hash, signature)
22+
}
23+
1724
export const vercelHandler: WebhookProviderHandler = {
18-
verifyAuth: createHmacVerifier({
19-
configKey: 'webhookSecret',
20-
headerName: 'x-vercel-signature',
21-
validateFn: (secret, signature, body) => {
22-
const hash = crypto.createHmac('sha1', secret).update(body, 'utf8').digest('hex')
23-
return safeCompare(hash, signature)
24-
},
25-
providerLabel: 'Vercel',
26-
}),
25+
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null {
26+
const secret = (providerConfig.webhookSecret as string | undefined)?.trim()
27+
if (!secret) {
28+
logger.warn(`[${requestId}] Vercel webhook secret missing; rejecting delivery`)
29+
return new NextResponse(
30+
'Unauthorized - Vercel webhook signing secret is not configured. Re-save the trigger so a webhook can be registered.',
31+
{ status: 401 }
32+
)
33+
}
34+
35+
const signature = request.headers.get('x-vercel-signature')
36+
if (!signature) {
37+
logger.warn(`[${requestId}] Vercel webhook missing x-vercel-signature header`)
38+
return new NextResponse('Unauthorized - Missing Vercel signature', { status: 401 })
39+
}
40+
41+
if (!verifyVercelSignature(secret, signature, rawBody)) {
42+
logger.warn(`[${requestId}] Vercel signature verification failed`)
43+
return new NextResponse('Unauthorized - Invalid Vercel signature', { status: 401 })
44+
}
45+
46+
return null
47+
},
48+
49+
async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
50+
const triggerId = providerConfig.triggerId as string | undefined
51+
const obj = body as Record<string, unknown>
52+
const eventType = obj.type as string | undefined
53+
54+
if (triggerId && triggerId !== 'vercel_webhook') {
55+
const { isVercelEventMatch } = await import('@/triggers/vercel/utils')
56+
if (!isVercelEventMatch(triggerId, eventType)) {
57+
logger.debug(`[${requestId}] Vercel event mismatch for trigger ${triggerId}. Skipping.`, {
58+
webhookId: webhook.id,
59+
workflowId: workflow.id,
60+
triggerId,
61+
eventType,
62+
})
63+
return false
64+
}
65+
}
66+
67+
return true
68+
},
69+
70+
extractIdempotencyId(body: unknown) {
71+
const id = (body as Record<string, unknown>)?.id
72+
if (id === undefined || id === null || id === '') {
73+
return null
74+
}
75+
return `vercel:${String(id)}`
76+
},
2777

2878
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
2979
const { webhook, requestId } = ctx
@@ -52,9 +102,8 @@ export const vercelHandler: WebhookProviderHandler = {
52102
}
53103

54104
if (triggerId && !(triggerId in eventTypeMap)) {
55-
logger.warn(
56-
`[${requestId}] Unknown triggerId for Vercel: ${triggerId}, defaulting to all events`,
57-
{ triggerId, webhookId: webhook.id }
105+
throw new Error(
106+
`Unknown Vercel trigger "${triggerId}". Remove and re-add the Vercel trigger, then save again.`
58107
)
59108
}
60109

@@ -147,10 +196,17 @@ export const vercelHandler: WebhookProviderHandler = {
147196
{ vercelWebhookId: externalId }
148197
)
149198

199+
const signingSecret = responseBody.secret as string | undefined
200+
if (!signingSecret) {
201+
throw new Error(
202+
'Vercel webhook was created but no signing secret was returned. Delete the webhook in Vercel and save this trigger again.'
203+
)
204+
}
205+
150206
return {
151207
providerConfigUpdates: {
152208
externalId,
153-
webhookSecret: (responseBody.secret as string) || '',
209+
webhookSecret: signingSecret,
154210
},
155211
}
156212
} catch (error: unknown) {
@@ -206,20 +262,77 @@ export const vercelHandler: WebhookProviderHandler = {
206262
const body = ctx.body as Record<string, unknown>
207263
const payload = (body.payload || {}) as Record<string, unknown>
208264

265+
const deployment = payload.deployment ?? null
266+
const project = payload.project ?? null
267+
const team = payload.team ?? null
268+
const user = payload.user ?? null
269+
const domain = payload.domain ?? null
270+
209271
return {
210272
input: {
211-
type: body.type || '',
212-
id: body.id || '',
213-
createdAt: body.createdAt || 0,
214-
region: body.region || null,
273+
type: body.type ?? '',
274+
id: body.id != null ? String(body.id) : '',
275+
createdAt: (() => {
276+
const v = body.createdAt
277+
if (typeof v === 'number' && !Number.isNaN(v)) {
278+
return v
279+
}
280+
if (typeof v === 'string') {
281+
const parsed = Date.parse(v)
282+
return Number.isNaN(parsed) ? 0 : parsed
283+
}
284+
const n = Number(v)
285+
return Number.isNaN(n) ? 0 : n
286+
})(),
287+
region: body.region != null ? String(body.region) : null,
215288
payload,
216-
deployment: payload.deployment || null,
217-
project: payload.project || null,
218-
team: payload.team || null,
219-
user: payload.user || null,
220-
target: payload.target || null,
221-
plan: payload.plan || null,
222-
domain: payload.domain || null,
289+
deployment:
290+
deployment && typeof deployment === 'object'
291+
? {
292+
id:
293+
(deployment as Record<string, unknown>).id != null
294+
? String((deployment as Record<string, unknown>).id)
295+
: '',
296+
url: ((deployment as Record<string, unknown>).url as string) ?? '',
297+
name: ((deployment as Record<string, unknown>).name as string) ?? '',
298+
}
299+
: null,
300+
project:
301+
project && typeof project === 'object'
302+
? {
303+
id:
304+
(project as Record<string, unknown>).id != null
305+
? String((project as Record<string, unknown>).id)
306+
: '',
307+
name: ((project as Record<string, unknown>).name as string) ?? '',
308+
}
309+
: null,
310+
team:
311+
team && typeof team === 'object'
312+
? {
313+
id:
314+
(team as Record<string, unknown>).id != null
315+
? String((team as Record<string, unknown>).id)
316+
: '',
317+
}
318+
: null,
319+
user:
320+
user && typeof user === 'object'
321+
? {
322+
id:
323+
(user as Record<string, unknown>).id != null
324+
? String((user as Record<string, unknown>).id)
325+
: '',
326+
}
327+
: null,
328+
target: payload.target != null ? String(payload.target) : null,
329+
plan: payload.plan != null ? String(payload.plan) : null,
330+
domain:
331+
domain && typeof domain === 'object'
332+
? {
333+
name: ((domain as Record<string, unknown>).name as string) ?? '',
334+
}
335+
: null,
223336
},
224337
}
225338
},
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { isGreenhouseEventMatch } from '@/triggers/greenhouse/utils'
6+
7+
describe('isGreenhouseEventMatch', () => {
8+
it('matches mapped trigger ids to Greenhouse action strings', () => {
9+
expect(isGreenhouseEventMatch('greenhouse_new_application', 'new_candidate_application')).toBe(
10+
true
11+
)
12+
expect(isGreenhouseEventMatch('greenhouse_new_application', 'hire_candidate')).toBe(false)
13+
})
14+
15+
it('rejects unknown trigger ids (no permissive fallback)', () => {
16+
expect(isGreenhouseEventMatch('greenhouse_unknown', 'new_candidate_application')).toBe(false)
17+
})
18+
})

0 commit comments

Comments
 (0)