Skip to content

Commit e79c556

Browse files
committed
fix(webhooks): tighten remaining provider hardening
Close the remaining pre-merge caveats by tightening Salesforce, Zoom, and Linear behavior, and follow through on the deferred provider and tooling cleanup for Vercel, Greenhouse, Gong, and Notion. Made-with: Cursor
1 parent 729667a commit e79c556

File tree

19 files changed

+446
-64
lines changed

19 files changed

+446
-64
lines changed

.agents/skills/add-trigger/SKILL.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -691,13 +691,13 @@ export const {service}WebhookTrigger: TriggerConfig = {
691691
### Automatic Webhook Registration (if supported)
692692
- [ ] Added API key field to `build{Service}ExtraFields` with `password: true`
693693
- [ ] Updated setup instructions for automatic webhook creation
694-
- [ ] Added provider-specific logic to `apps/sim/app/api/webhooks/route.ts`
695-
- [ ] Added `create{Service}WebhookSubscription` helper function
696-
- [ ] Added `delete{Service}Webhook` function to `provider-subscriptions.ts`
697-
- [ ] Added provider to `cleanupExternalWebhook` function
694+
- [ ] Added `createSubscription` to `apps/sim/lib/webhooks/providers/{service}.ts`
695+
- [ ] Added `deleteSubscription` to `apps/sim/lib/webhooks/providers/{service}.ts`
696+
- [ ] Did not add provider-specific orchestration logic to shared route / deploy / provider-subscriptions files unless absolutely required
698697
699698
### Webhook Input Formatting
700-
- [ ] Added handler in `apps/sim/lib/webhooks/utils.server.ts` (if custom formatting needed)
699+
- [ ] Added provider-owned `formatInput` in `apps/sim/lib/webhooks/providers/{service}.ts` when custom formatting is needed
700+
- [ ] Used `createHmacVerifier` for standard HMAC providers, or a custom `verifyAuth()` when the provider requires non-standard signature semantics / stricter secret handling
701701
- [ ] Handler returns fields matching trigger `outputs` exactly
702702
- [ ] Run `bun run apps/sim/scripts/check-trigger-alignment.ts {service}` to verify alignment
703703

.claude/commands/add-trigger.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -806,7 +806,7 @@ export const {service}WebhookTrigger: TriggerConfig = {
806806
- [ ] Created handler file in `apps/sim/lib/webhooks/providers/{service}.ts`
807807
- [ ] Registered handler in `apps/sim/lib/webhooks/providers/registry.ts` (alphabetical)
808808
- [ ] Signature validator defined as private function inside handler file (not in a shared file)
809-
- [ ] Used `createHmacVerifier` from `providers/utils` for HMAC-based auth
809+
- [ ] Used `createHmacVerifier` from `providers/utils` for standard HMAC auth, or a provider-specific `verifyAuth()` when the provider requires custom signature semantics / stricter secret handling
810810
- [ ] Used `verifyTokenAuth` from `providers/utils` for token-based auth
811811
- [ ] Event matching uses dynamic `await import()` for trigger utils
812812
- [ ] Added `formatInput` if webhook payload needs transformation (returns `{ input: ... }`)

apps/sim/lib/webhooks/providers/gong.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@ describe('normalizeGongPublicKeyPem', () => {
2828
})
2929
})
3030

31+
describe('gongHandler formatInput', () => {
32+
it('always returns callId as a string', async () => {
33+
const { input } = await gongHandler.formatInput!({
34+
webhook: {},
35+
workflow: { id: 'wf', userId: 'u' },
36+
body: { callData: { metaData: {} } },
37+
headers: {},
38+
requestId: 'gong-format',
39+
})
40+
41+
expect((input as Record<string, unknown>).callId).toBe('')
42+
})
43+
})
44+
3145
describe('gongHandler verifyAuth (JWT)', () => {
3246
it('returns null when JWT public key is not configured', async () => {
3347
const request = new NextRequest('https://app.example.com/api/webhooks/trigger/abc', {

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,7 @@ export const gongHandler: WebhookProviderHandler = {
129129
const metaData = (callData?.metaData as Record<string, unknown>) || {}
130130
const content = callData?.content as Record<string, unknown> | undefined
131131
const callId =
132-
typeof metaData.id === 'string' || typeof metaData.id === 'number'
133-
? String(metaData.id)
134-
: null
132+
typeof metaData.id === 'string' || typeof metaData.id === 'number' ? String(metaData.id) : ''
135133

136134
return {
137135
input: {
@@ -142,7 +140,7 @@ export const gongHandler: WebhookProviderHandler = {
142140
context: (callData?.context as unknown[]) || [],
143141
trackers: (content?.trackers as unknown[]) || [],
144142
eventType: 'gong.automation_rule',
145-
callId: callId ?? null,
143+
callId,
146144
},
147145
}
148146
},

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,14 @@ export const greenhouseHandler: WebhookProviderHandler = {
9898
return `greenhouse:${action}:offer:${String(offerId)}:${v}`
9999
}
100100

101+
const offer = (payload.offer || {}) as Record<string, unknown>
102+
const nestedOfferId = offer.id
103+
if (nestedOfferId !== undefined && nestedOfferId !== null && nestedOfferId !== '') {
104+
const nestedVersion =
105+
offer.version !== undefined && offer.version !== null ? String(offer.version) : '0'
106+
return `greenhouse:${action}:offer:${String(nestedOfferId)}:${nestedVersion}`
107+
}
108+
101109
const job = (payload.job || {}) as Record<string, unknown>
102110
const jobId = job.id
103111
if (jobId !== undefined && jobId !== null && jobId !== '') {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import crypto from 'node:crypto'
2+
import { NextRequest } from 'next/server'
3+
import { describe, expect, it } from 'vitest'
4+
import { linearHandler } from '@/lib/webhooks/providers/linear'
5+
6+
function signLinearBody(secret: string, rawBody: string): string {
7+
return crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')
8+
}
9+
10+
function requestWithLinearSignature(secret: string, rawBody: string): NextRequest {
11+
const signature = signLinearBody(secret, rawBody)
12+
return new NextRequest('http://localhost/test', {
13+
headers: {
14+
'Linear-Signature': signature,
15+
},
16+
})
17+
}
18+
19+
describe('Linear webhook provider', () => {
20+
it('rejects signed requests when webhookTimestamp is missing', async () => {
21+
const secret = 'linear-secret'
22+
const rawBody = JSON.stringify({
23+
action: 'create',
24+
type: 'Issue',
25+
})
26+
27+
const res = await linearHandler.verifyAuth!({
28+
request: requestWithLinearSignature(secret, rawBody),
29+
rawBody,
30+
requestId: 'linear-t1',
31+
providerConfig: { webhookSecret: secret },
32+
webhook: {},
33+
workflow: {},
34+
})
35+
36+
expect(res?.status).toBe(401)
37+
})
38+
39+
it('rejects signed requests when webhookTimestamp skew is too large', async () => {
40+
const secret = 'linear-secret'
41+
const rawBody = JSON.stringify({
42+
action: 'update',
43+
type: 'Issue',
44+
webhookTimestamp: Date.now() - 120_000,
45+
})
46+
47+
const res = await linearHandler.verifyAuth!({
48+
request: requestWithLinearSignature(secret, rawBody),
49+
rawBody,
50+
requestId: 'linear-t2',
51+
providerConfig: { webhookSecret: secret },
52+
webhook: {},
53+
workflow: {},
54+
})
55+
56+
expect(res?.status).toBe(401)
57+
})
58+
59+
it('accepts signed requests within the allowed timestamp window', async () => {
60+
const secret = 'linear-secret'
61+
const rawBody = JSON.stringify({
62+
action: 'update',
63+
type: 'Issue',
64+
webhookTimestamp: Date.now(),
65+
})
66+
67+
const res = await linearHandler.verifyAuth!({
68+
request: requestWithLinearSignature(secret, rawBody),
69+
rawBody,
70+
requestId: 'linear-t3',
71+
providerConfig: { webhookSecret: secret },
72+
webhook: {},
73+
workflow: {},
74+
})
75+
76+
expect(res).toBeNull()
77+
})
78+
})

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

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ function validateLinearSignature(secret: string, signature: string, body: string
4242
}
4343
}
4444

45-
const LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS = 5 * 60 * 1000
45+
const LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS = 60 * 1000
4646

4747
export const linearHandler: WebhookProviderHandler = {
4848
async verifyAuth({
@@ -70,15 +70,20 @@ export const linearHandler: WebhookProviderHandler = {
7070
try {
7171
const parsed = JSON.parse(rawBody) as Record<string, unknown>
7272
const ts = parsed.webhookTimestamp
73-
if (typeof ts === 'number' && Number.isFinite(ts)) {
74-
if (Math.abs(Date.now() - ts) > LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS) {
75-
logger.warn(
76-
`[${requestId}] Linear webhookTimestamp outside allowed skew (${LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS}ms)`
77-
)
78-
return new NextResponse('Unauthorized - Webhook timestamp skew too large', {
79-
status: 401,
80-
})
81-
}
73+
if (typeof ts !== 'number' || !Number.isFinite(ts)) {
74+
logger.warn(`[${requestId}] Linear webhookTimestamp missing or invalid`)
75+
return new NextResponse('Unauthorized - Invalid webhook timestamp', {
76+
status: 401,
77+
})
78+
}
79+
80+
if (Math.abs(Date.now() - ts) > LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS) {
81+
logger.warn(
82+
`[${requestId}] Linear webhookTimestamp outside allowed skew (${LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS}ms)`
83+
)
84+
return new NextResponse('Unauthorized - Webhook timestamp skew too large', {
85+
status: 401,
86+
})
8287
}
8388
} catch (error) {
8489
logger.warn(
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { notionHandler } from '@/lib/webhooks/providers/notion'
3+
import { isNotionPayloadMatch } from '@/triggers/notion/utils'
4+
5+
describe('Notion webhook provider', () => {
6+
it('matches both legacy and newer schema updated event names', () => {
7+
expect(
8+
isNotionPayloadMatch('notion_database_schema_updated', {
9+
type: 'database.schema_updated',
10+
})
11+
).toBe(true)
12+
13+
expect(
14+
isNotionPayloadMatch('notion_database_schema_updated', {
15+
type: 'data_source.schema_updated',
16+
})
17+
).toBe(true)
18+
})
19+
20+
it('builds a stable idempotency key from event type and id', () => {
21+
const key = notionHandler.extractIdempotencyId!({
22+
id: 'evt_123',
23+
type: 'page.created',
24+
})
25+
26+
expect(key).toBe('notion:page.created:evt_123')
27+
})
28+
})

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,4 +135,17 @@ export const notionHandler: WebhookProviderHandler = {
135135

136136
return true
137137
},
138+
139+
extractIdempotencyId(body: unknown) {
140+
const obj = body as Record<string, unknown>
141+
const id = obj.id
142+
const type = obj.type
143+
if (
144+
(typeof id === 'string' || typeof id === 'number') &&
145+
(typeof type === 'string' || typeof type === 'number')
146+
) {
147+
return `notion:${String(type)}:${String(id)}`
148+
}
149+
return null
150+
},
138151
}

apps/sim/lib/webhooks/providers/salesforce-zoom-webhook.test.ts renamed to apps/sim/lib/webhooks/providers/salesforce.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ describe('Salesforce webhook provider', () => {
6464
expect(
6565
isSalesforceEventMatch('salesforce_webhook', { objectType: 'Contact', Id: 'x' }, 'Account')
6666
).toBe(false)
67+
expect(isSalesforceEventMatch('salesforce_webhook', { Id: 'x' }, 'Account')).toBe(false)
68+
})
69+
70+
it('isSalesforceEventMatch fails closed for record triggers when configured objectType is missing', () => {
71+
expect(
72+
isSalesforceEventMatch(
73+
'salesforce_record_created',
74+
{ eventType: 'created', Id: '001' },
75+
'Account'
76+
)
77+
).toBe(false)
6778
})
6879

6980
it('formatInput maps record trigger fields', async () => {
@@ -92,6 +103,23 @@ describe('Salesforce webhook provider', () => {
92103
})
93104
expect(id).toContain('001')
94105
})
106+
107+
it('extractIdempotencyId is stable without timestamps for identical payloads', () => {
108+
const body = {
109+
eventType: 'updated',
110+
objectType: 'Account',
111+
Id: '001',
112+
Name: 'Acme',
113+
changedFields: ['Name'],
114+
}
115+
116+
const first = salesforceHandler.extractIdempotencyId!(body)
117+
const second = salesforceHandler.extractIdempotencyId!({ ...body })
118+
119+
expect(first).toBe(second)
120+
expect(first).toContain('001')
121+
expect(first).toContain('updated')
122+
})
95123
})
96124

97125
describe('Zoom webhook provider', () => {
@@ -121,4 +149,32 @@ describe('Zoom webhook provider', () => {
121149
})
122150
expect(zid).toBe('zoom:meeting.started:123:u1')
123151
})
152+
153+
it('extractIdempotencyId uses participant identity when available', () => {
154+
const zid = zoomHandler.extractIdempotencyId!({
155+
event: 'meeting.participant_joined',
156+
event_ts: 123,
157+
payload: {
158+
object: {
159+
uuid: 'meeting-uuid',
160+
participant: {
161+
user_id: 'participant-1',
162+
},
163+
},
164+
},
165+
})
166+
expect(zid).toBe('zoom:meeting.participant_joined:123:participant-1')
167+
})
168+
169+
it('matchEvent never executes endpoint validation payloads', async () => {
170+
const result = await zoomHandler.matchEvent!({
171+
webhook: { id: 'w' },
172+
workflow: { id: 'wf' },
173+
body: { event: 'endpoint.url_validation' },
174+
request: reqWithHeaders({}),
175+
requestId: 't5',
176+
providerConfig: { triggerId: 'zoom_webhook' },
177+
})
178+
expect(result).toBe(false)
179+
})
124180
})

0 commit comments

Comments
 (0)