Skip to content

Commit 599654b

Browse files
authored
fix(slack): harden native webhook configuration (#7566)
* fix(slack): harden native webhook configuration * fix(slack): complete setup remediation * fix(slack): fail closed without webhook secret * fix(slack): align setup checks with runtime
1 parent 04322e0 commit 599654b

10 files changed

Lines changed: 278 additions & 21 deletions

File tree

apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ Webhook triggers receive callbacks from the provider and must be able to verify
196196
| `SLACK_SIGNING_SECRET` | Verifying Slack event and slash-command signatures |
197197
| `SLACK_EXTENDED_SCOPES` / `NEXT_PUBLIC_SLACK_EXTENDED_SCOPES` | Enabling the native Sim-app trigger and its broader Slack scope set; set both to the same value |
198198

199+
When enabling the native Sim Slack trigger, configure all three variables together. Enable the extended-scope flags only after Slack approves the app for `assistant:write`, `app_mentions:read`, and `im:history`; otherwise Slack rejects OAuth authorization. Slack OAuth actions can use `SLACK_CLIENT_ID` and `SLACK_CLIENT_SECRET` without enabling the native trigger or supplying a signing secret.
200+
199201
Your deployment must also be reachable from the provider's servers for webhook triggers to fire — a Sim instance on a private network can use polling triggers but not webhook triggers. Polling triggers additionally require the scheduler; see [Background Jobs](/platform/self-hosting/background-jobs).
200202

201203
<FAQ items={[

apps/sim/app/api/webhooks/slack/route.test.ts

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,19 @@
44
import { resetEnvMock, setEnv } from '@sim/testing'
55
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
66

7-
const { mockParseWebhookBody, mockFindWebhooksByRoutingKey, mockDispatchResolvedWebhookTarget } =
8-
vi.hoisted(() => ({
9-
mockParseWebhookBody: vi.fn(),
10-
mockFindWebhooksByRoutingKey: vi.fn(),
11-
mockDispatchResolvedWebhookTarget: vi.fn(),
12-
}))
7+
const {
8+
mockParseWebhookBody,
9+
mockFindWebhooksByRoutingKey,
10+
mockDispatchResolvedWebhookTarget,
11+
mockHandleSlackChallenge,
12+
mockVerifySlackRequestSignature,
13+
} = vi.hoisted(() => ({
14+
mockParseWebhookBody: vi.fn(),
15+
mockFindWebhooksByRoutingKey: vi.fn(),
16+
mockDispatchResolvedWebhookTarget: vi.fn(),
17+
mockHandleSlackChallenge: vi.fn(),
18+
mockVerifySlackRequestSignature: vi.fn(),
19+
}))
1320

1421
vi.mock('@/lib/core/admission/gate', () => ({
1522
tryAdmit: () => ({ release: vi.fn() }),
@@ -23,8 +30,8 @@ vi.mock('@/lib/webhooks/processor', () => ({
2330
}))
2431

2532
vi.mock('@/lib/webhooks/providers/slack', () => ({
26-
handleSlackChallenge: () => null,
27-
verifySlackRequestSignature: () => null,
33+
handleSlackChallenge: mockHandleSlackChallenge,
34+
verifySlackRequestSignature: mockVerifySlackRequestSignature,
2835
resolveSlackEventKey: () => null,
2936
}))
3037

@@ -60,6 +67,8 @@ describe('Slack app webhook route', () => {
6067
beforeEach(() => {
6168
vi.clearAllMocks()
6269
setEnv({ SLACK_SIGNING_SECRET: 'test-secret' })
70+
mockHandleSlackChallenge.mockReturnValue(null)
71+
mockVerifySlackRequestSignature.mockReturnValue(null)
6372
mockFindWebhooksByRoutingKey.mockResolvedValue([webhook('wh1')])
6473
mockDispatchResolvedWebhookTarget.mockResolvedValue({
6574
outcome: 'queued',
@@ -70,9 +79,63 @@ describe('Slack app webhook route', () => {
7079

7180
it('dispatches each webhook resolved for the event team', async () => {
7281
await run(messageBody)
82+
expect(mockVerifySlackRequestSignature).toHaveBeenCalledWith(
83+
'test-secret',
84+
expect.anything(),
85+
JSON.stringify(messageBody),
86+
expect.any(String)
87+
)
7388
expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1)
7489
})
7590

91+
it('rejects a verification challenge when the native app is not configured', async () => {
92+
setEnv({ SLACK_SIGNING_SECRET: undefined })
93+
mockHandleSlackChallenge.mockReturnValue(new Response('challenge', { status: 200 }))
94+
95+
const response = await run({ type: 'url_verification', challenge: 'challenge' })
96+
97+
expect(response.status).toBe(500)
98+
expect(mockVerifySlackRequestSignature).not.toHaveBeenCalled()
99+
expect(mockHandleSlackChallenge).not.toHaveBeenCalled()
100+
})
101+
102+
it('treats a whitespace-only native signing secret as unconfigured', async () => {
103+
setEnv({ SLACK_SIGNING_SECRET: ' ' })
104+
105+
const response = await run(messageBody)
106+
107+
expect(response.status).toBe(500)
108+
expect(mockVerifySlackRequestSignature).not.toHaveBeenCalled()
109+
expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled()
110+
})
111+
112+
it('verifies a signed request before answering the verification challenge', async () => {
113+
const body = { type: 'url_verification', challenge: 'challenge' }
114+
mockHandleSlackChallenge.mockReturnValue(new Response('challenge', { status: 200 }))
115+
116+
const response = await run(body)
117+
118+
expect(mockVerifySlackRequestSignature).toHaveBeenCalledWith(
119+
'test-secret',
120+
expect.anything(),
121+
JSON.stringify(body),
122+
expect.any(String)
123+
)
124+
expect(mockHandleSlackChallenge).toHaveBeenCalledWith(body)
125+
expect(response.status).toBe(200)
126+
expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled()
127+
})
128+
129+
it('does not answer a verification challenge with an invalid signature', async () => {
130+
mockVerifySlackRequestSignature.mockReturnValue(new Response(null, { status: 401 }))
131+
mockHandleSlackChallenge.mockReturnValue(new Response('challenge', { status: 200 }))
132+
133+
const response = await run({ type: 'url_verification', challenge: 'challenge' })
134+
135+
expect(response.status).toBe(401)
136+
expect(mockHandleSlackChallenge).not.toHaveBeenCalled()
137+
})
138+
76139
it('continues cleanly when the dispatcher filters the event', async () => {
77140
mockDispatchResolvedWebhookTarget.mockResolvedValue({
78141
outcome: 'ignored',

apps/sim/app/api/webhooks/slack/route.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
4-
import { env } from '@/lib/core/config/env'
54
import { generateRequestId } from '@/lib/core/utils/request'
65
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
76
import { findWebhooksByRoutingKey, parseWebhookBody } from '@/lib/webhooks/processor'
87
import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack'
98
import { dispatchSlackWebhooks, getSlackDispatchResponse } from '@/lib/webhooks/slack-dispatch'
9+
import { getSlackNativeSigningSecret } from '@/lib/webhooks/slack-native-config'
1010

1111
const logger = createLogger('SlackAppWebhookAPI')
1212

@@ -44,13 +44,7 @@ async function handleSlackAppWebhook(request: NextRequest): Promise<NextResponse
4444
}
4545
const { body, rawBody } = parseResult
4646

47-
// Slack's endpoint verification handshake — echo the challenge back.
48-
const challenge = handleSlackChallenge(body)
49-
if (challenge) {
50-
return challenge
51-
}
52-
53-
const signingSecret = env.SLACK_SIGNING_SECRET
47+
const signingSecret = getSlackNativeSigningSecret()
5448
if (!signingSecret) {
5549
logger.error(`[${requestId}] SLACK_SIGNING_SECRET is not configured`)
5650
return new NextResponse('Slack app not configured', { status: 500 })
@@ -61,6 +55,11 @@ async function handleSlackAppWebhook(request: NextRequest): Promise<NextResponse
6155
return authError
6256
}
6357

58+
const challenge = handleSlackChallenge(body)
59+
if (challenge) {
60+
return challenge
61+
}
62+
6463
const payload = body as Record<string, unknown>
6564

6665
// Route by the installed workspace(s). For Slack Connect the outer `team_id`

apps/sim/lib/webhooks/deploy.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
queueTableRows,
88
resetDbChainMock,
99
resetEnvFlagsMock,
10+
resetEnvMock,
11+
setEnv,
1012
setEnvFlags,
1113
} from '@sim/testing'
1214
import { eq, ne } from 'drizzle-orm'
@@ -84,6 +86,7 @@ import { getTrigger } from '@/triggers'
8486

8587
afterAll(() => {
8688
resetDbChainMock()
89+
resetEnvMock()
8790
resetEnvFlagsMock()
8891
})
8992

@@ -151,6 +154,7 @@ function makeBlock(
151154
beforeEach(() => {
152155
vi.clearAllMocks()
153156
resetDbChainMock()
157+
setEnv({ SLACK_SIGNING_SECRET: 'test-secret' })
154158
setEnvFlags({ isSlackExtendedScopesEnabled: true })
155159
;(getProviderHandler as unknown as Mock).mockImplementation((provider: string) =>
156160
provider === 'quickbooks' ? quickBooksHandler : {}
@@ -301,8 +305,9 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => {
301305
})
302306
}
303307

304-
it('routes a custom bot credential by credential id on the slack provider', async () => {
308+
it('routes a custom bot credential without the native app signing secret', async () => {
305309
setEnvFlags({ isSlackExtendedScopesEnabled: false })
310+
setEnv({ SLACK_SIGNING_SECRET: undefined })
306311
mockGetSlackBotCredential.mockResolvedValue({
307312
workspaceId: 'ws-1',
308313
botToken: 'xoxb-token',
@@ -403,6 +408,24 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => {
403408
expect(mockFetchSlackTeamId).not.toHaveBeenCalled()
404409
})
405410

411+
it('rejects a Sim-app credential when its signing secret is not configured', async () => {
412+
setEnv({ SLACK_SIGNING_SECRET: undefined })
413+
mockGetSlackBotCredential.mockResolvedValue(null)
414+
mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' })
415+
416+
const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_oauth_1' })
417+
418+
expect(result?.success).toBe(false)
419+
if (result?.success) throw new Error('expected failure')
420+
expect(result?.error).toEqual({
421+
message:
422+
'The Sim Slack app trigger is not configured for this deployment. Configure its signing secret or select a custom bot.',
423+
status: 400,
424+
})
425+
expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled()
426+
expect(mockFetchSlackTeamId).not.toHaveBeenCalled()
427+
})
428+
406429
it('rejects a custom bot credential from another workspace', async () => {
407430
mockGetSlackBotCredential.mockResolvedValue({
408431
workspaceId: 'other-ws',

apps/sim/lib/webhooks/deploy.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
type StableDesiredWebhookRegistration,
2929
} from '@/lib/webhooks/registration-service'
3030
import { LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE } from '@/lib/webhooks/slack-custom-ingress-constants'
31+
import { getSlackNativeSigningSecret } from '@/lib/webhooks/slack-native-config'
3132
import {
3233
isSlackStreamResponseRequested,
3334
normalizeSlackStreamResponseConfig,
@@ -517,6 +518,16 @@ export async function resolveWebhookConfigForBlock(input: {
517518
},
518519
}
519520
}
521+
if (!getSlackNativeSigningSecret()) {
522+
return {
523+
success: false,
524+
error: {
525+
message:
526+
'The Sim Slack app trigger is not configured for this deployment. Configure its signing secret or select a custom bot.',
527+
status: 400,
528+
},
529+
}
530+
}
520531
if (isSlackStreamResponseRequested(providerConfig)) {
521532
return {
522533
success: false,

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHmac } from 'node:crypto'
12
import { describe, expect, it } from 'vitest'
23
import {
34
handleSlackChallenge,
@@ -23,6 +24,66 @@ describe('slackHandler responses', () => {
2324
})
2425
})
2526

27+
describe('slackHandler request verification', () => {
28+
const rawBody = JSON.stringify({ type: 'event_callback' })
29+
30+
function signedRequest(signingSecret: string, timestamp: string, body = rawBody): Request {
31+
const signature = createHmac('sha256', signingSecret)
32+
.update(`v0:${timestamp}:${body}`, 'utf8')
33+
.digest('hex')
34+
return new Request('https://sim.test/api/webhooks/trigger/slack', {
35+
method: 'POST',
36+
headers: {
37+
'x-slack-request-timestamp': timestamp,
38+
'x-slack-signature': `v0=${signature}`,
39+
},
40+
})
41+
}
42+
43+
function verify(request: Request, providerConfig: Record<string, unknown>, body = rawBody) {
44+
return slackHandler.verifyAuth!({
45+
webhook: {},
46+
workflow: {},
47+
request: request as unknown as import('next/server').NextRequest,
48+
rawBody: body,
49+
requestId: 'slack-auth-test',
50+
providerConfig,
51+
})
52+
}
53+
54+
it('fails closed when a legacy Slack webhook has no signing secret', async () => {
55+
const response = await verify(new Request('https://sim.test'), {})
56+
57+
expect(response?.status).toBe(401)
58+
})
59+
60+
it('accepts a correctly signed current request', async () => {
61+
const signingSecret = 'test-signing-secret'
62+
const timestamp = String(Math.floor(Date.now() / 1000))
63+
64+
expect(verify(signedRequest(signingSecret, timestamp), { signingSecret })).toBeNull()
65+
})
66+
67+
it('rejects a signature computed for different raw bytes', async () => {
68+
const signingSecret = 'test-signing-secret'
69+
const timestamp = String(Math.floor(Date.now() / 1000))
70+
const request = signedRequest(signingSecret, timestamp)
71+
72+
const response = await verify(request, { signingSecret }, `${rawBody} `)
73+
74+
expect(response?.status).toBe(401)
75+
})
76+
77+
it("rejects an otherwise valid signature outside Slack's five-minute replay window", async () => {
78+
const signingSecret = 'test-signing-secret'
79+
const timestamp = String(Math.floor(Date.now() / 1000) - 301)
80+
81+
const response = await verify(signedRequest(signingSecret, timestamp), { signingSecret })
82+
83+
expect(response?.status).toBe(401)
84+
})
85+
})
86+
2687
describe('slackHandler formatInput - Events API', () => {
2788
it('maps an app_mention event', async () => {
2889
const { input } = await slackHandler.formatInput!(

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -840,7 +840,8 @@ export const slackHandler: WebhookProviderHandler = {
840840
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
841841
const signingSecret = providerConfig.signingSecret as string | undefined
842842
if (!signingSecret) {
843-
return null
843+
logger.warn(`[${requestId}] Slack webhook signing secret not configured`)
844+
return new NextResponse('Unauthorized - Missing Slack signing secret', { status: 401 })
844845
}
845846
return verifySlackRequestSignature(signingSecret, request, rawBody, requestId)
846847
},
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { env } from '@/lib/core/config/env'
2+
3+
/** Returns the signing secret for the native Sim Slack app when it is configured. */
4+
export function getSlackNativeSigningSecret(): string | null {
5+
const signingSecret = env.SLACK_SIGNING_SECRET?.trim()
6+
return signingSecret || null
7+
}

0 commit comments

Comments
 (0)