Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ Webhook triggers receive callbacks from the provider and must be able to verify
| `SLACK_SIGNING_SECRET` | Verifying Slack event and slash-command signatures |
| `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 |

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.

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).

<FAQ items={[
Expand Down
79 changes: 71 additions & 8 deletions apps/sim/app/api/webhooks/slack/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@
import { resetEnvMock, setEnv } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockParseWebhookBody, mockFindWebhooksByRoutingKey, mockDispatchResolvedWebhookTarget } =
vi.hoisted(() => ({
mockParseWebhookBody: vi.fn(),
mockFindWebhooksByRoutingKey: vi.fn(),
mockDispatchResolvedWebhookTarget: vi.fn(),
}))
const {
mockParseWebhookBody,
mockFindWebhooksByRoutingKey,
mockDispatchResolvedWebhookTarget,
mockHandleSlackChallenge,
mockVerifySlackRequestSignature,
} = vi.hoisted(() => ({
mockParseWebhookBody: vi.fn(),
mockFindWebhooksByRoutingKey: vi.fn(),
mockDispatchResolvedWebhookTarget: vi.fn(),
mockHandleSlackChallenge: vi.fn(),
mockVerifySlackRequestSignature: vi.fn(),
}))

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

vi.mock('@/lib/webhooks/providers/slack', () => ({
handleSlackChallenge: () => null,
verifySlackRequestSignature: () => null,
handleSlackChallenge: mockHandleSlackChallenge,
verifySlackRequestSignature: mockVerifySlackRequestSignature,
resolveSlackEventKey: () => null,
}))

Expand Down Expand Up @@ -60,6 +67,8 @@ describe('Slack app webhook route', () => {
beforeEach(() => {
vi.clearAllMocks()
setEnv({ SLACK_SIGNING_SECRET: 'test-secret' })
mockHandleSlackChallenge.mockReturnValue(null)
mockVerifySlackRequestSignature.mockReturnValue(null)
mockFindWebhooksByRoutingKey.mockResolvedValue([webhook('wh1')])
mockDispatchResolvedWebhookTarget.mockResolvedValue({
outcome: 'queued',
Expand All @@ -70,9 +79,63 @@ describe('Slack app webhook route', () => {

it('dispatches each webhook resolved for the event team', async () => {
await run(messageBody)
expect(mockVerifySlackRequestSignature).toHaveBeenCalledWith(
'test-secret',
expect.anything(),
JSON.stringify(messageBody),
expect.any(String)
)
expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1)
})

it('rejects a verification challenge when the native app is not configured', async () => {
setEnv({ SLACK_SIGNING_SECRET: undefined })
mockHandleSlackChallenge.mockReturnValue(new Response('challenge', { status: 200 }))

const response = await run({ type: 'url_verification', challenge: 'challenge' })

expect(response.status).toBe(500)
expect(mockVerifySlackRequestSignature).not.toHaveBeenCalled()
expect(mockHandleSlackChallenge).not.toHaveBeenCalled()
})

it('treats a whitespace-only native signing secret as unconfigured', async () => {
setEnv({ SLACK_SIGNING_SECRET: ' ' })

const response = await run(messageBody)

expect(response.status).toBe(500)
expect(mockVerifySlackRequestSignature).not.toHaveBeenCalled()
expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled()
})

it('verifies a signed request before answering the verification challenge', async () => {
const body = { type: 'url_verification', challenge: 'challenge' }
mockHandleSlackChallenge.mockReturnValue(new Response('challenge', { status: 200 }))

const response = await run(body)

expect(mockVerifySlackRequestSignature).toHaveBeenCalledWith(
'test-secret',
expect.anything(),
JSON.stringify(body),
expect.any(String)
)
expect(mockHandleSlackChallenge).toHaveBeenCalledWith(body)
expect(response.status).toBe(200)
expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled()
})

it('does not answer a verification challenge with an invalid signature', async () => {
mockVerifySlackRequestSignature.mockReturnValue(new Response(null, { status: 401 }))
mockHandleSlackChallenge.mockReturnValue(new Response('challenge', { status: 200 }))

const response = await run({ type: 'url_verification', challenge: 'challenge' })

expect(response.status).toBe(401)
expect(mockHandleSlackChallenge).not.toHaveBeenCalled()
})

it('continues cleanly when the dispatcher filters the event', async () => {
mockDispatchResolvedWebhookTarget.mockResolvedValue({
outcome: 'ignored',
Expand Down
15 changes: 7 additions & 8 deletions apps/sim/app/api/webhooks/slack/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { findWebhooksByRoutingKey, parseWebhookBody } from '@/lib/webhooks/processor'
import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack'
import { dispatchSlackWebhooks, getSlackDispatchResponse } from '@/lib/webhooks/slack-dispatch'
import { getSlackNativeSigningSecret } from '@/lib/webhooks/slack-native-config'

const logger = createLogger('SlackAppWebhookAPI')

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

// Slack's endpoint verification handshake — echo the challenge back.
const challenge = handleSlackChallenge(body)
if (challenge) {
return challenge
}

const signingSecret = env.SLACK_SIGNING_SECRET
const signingSecret = getSlackNativeSigningSecret()
if (!signingSecret) {
logger.error(`[${requestId}] SLACK_SIGNING_SECRET is not configured`)
return new NextResponse('Slack app not configured', { status: 500 })
Expand All @@ -61,6 +55,11 @@ async function handleSlackAppWebhook(request: NextRequest): Promise<NextResponse
return authError
}

const challenge = handleSlackChallenge(body)
if (challenge) {
return challenge
}

const payload = body as Record<string, unknown>

// Route by the installed workspace(s). For Slack Connect the outer `team_id`
Expand Down
25 changes: 24 additions & 1 deletion apps/sim/lib/webhooks/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
queueTableRows,
resetDbChainMock,
resetEnvFlagsMock,
resetEnvMock,
setEnv,
setEnvFlags,
} from '@sim/testing'
import { eq, ne } from 'drizzle-orm'
Expand Down Expand Up @@ -84,6 +86,7 @@ import { getTrigger } from '@/triggers'

afterAll(() => {
resetDbChainMock()
resetEnvMock()
resetEnvFlagsMock()
})

Expand Down Expand Up @@ -151,6 +154,7 @@ function makeBlock(
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
setEnv({ SLACK_SIGNING_SECRET: 'test-secret' })
setEnvFlags({ isSlackExtendedScopesEnabled: true })
;(getProviderHandler as unknown as Mock).mockImplementation((provider: string) =>
provider === 'quickbooks' ? quickBooksHandler : {}
Expand Down Expand Up @@ -301,8 +305,9 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => {
})
}

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

it('rejects a Sim-app credential when its signing secret is not configured', async () => {
setEnv({ SLACK_SIGNING_SECRET: undefined })
mockGetSlackBotCredential.mockResolvedValue(null)
mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' })

const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_oauth_1' })

expect(result?.success).toBe(false)
if (result?.success) throw new Error('expected failure')
expect(result?.error).toEqual({
message:
'The Sim Slack app trigger is not configured for this deployment. Configure its signing secret or select a custom bot.',
status: 400,
})
expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled()
expect(mockFetchSlackTeamId).not.toHaveBeenCalled()
})

it('rejects a custom bot credential from another workspace', async () => {
mockGetSlackBotCredential.mockResolvedValue({
workspaceId: 'other-ws',
Expand Down
11 changes: 11 additions & 0 deletions apps/sim/lib/webhooks/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
type StableDesiredWebhookRegistration,
} from '@/lib/webhooks/registration-service'
import { LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE } from '@/lib/webhooks/slack-custom-ingress-constants'
import { getSlackNativeSigningSecret } from '@/lib/webhooks/slack-native-config'
import {
isSlackStreamResponseRequested,
normalizeSlackStreamResponseConfig,
Expand Down Expand Up @@ -517,6 +518,16 @@ export async function resolveWebhookConfigForBlock(input: {
},
}
}
if (!getSlackNativeSigningSecret()) {
return {
success: false,
error: {
message:
'The Sim Slack app trigger is not configured for this deployment. Configure its signing secret or select a custom bot.',
status: 400,
},
}
}
if (isSlackStreamResponseRequested(providerConfig)) {
return {
success: false,
Expand Down
61 changes: 61 additions & 0 deletions apps/sim/lib/webhooks/providers/slack.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHmac } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import {
handleSlackChallenge,
Expand All @@ -23,6 +24,66 @@ describe('slackHandler responses', () => {
})
})

describe('slackHandler request verification', () => {
const rawBody = JSON.stringify({ type: 'event_callback' })

function signedRequest(signingSecret: string, timestamp: string, body = rawBody): Request {
const signature = createHmac('sha256', signingSecret)
.update(`v0:${timestamp}:${body}`, 'utf8')
.digest('hex')
return new Request('https://sim.test/api/webhooks/trigger/slack', {
method: 'POST',
headers: {
'x-slack-request-timestamp': timestamp,
'x-slack-signature': `v0=${signature}`,
},
})
}

function verify(request: Request, providerConfig: Record<string, unknown>, body = rawBody) {
return slackHandler.verifyAuth!({
webhook: {},
workflow: {},
request: request as unknown as import('next/server').NextRequest,
rawBody: body,
requestId: 'slack-auth-test',
providerConfig,
})
}

it('fails closed when a legacy Slack webhook has no signing secret', async () => {
const response = await verify(new Request('https://sim.test'), {})

expect(response?.status).toBe(401)
})

it('accepts a correctly signed current request', async () => {
const signingSecret = 'test-signing-secret'
const timestamp = String(Math.floor(Date.now() / 1000))

expect(verify(signedRequest(signingSecret, timestamp), { signingSecret })).toBeNull()
})

it('rejects a signature computed for different raw bytes', async () => {
const signingSecret = 'test-signing-secret'
const timestamp = String(Math.floor(Date.now() / 1000))
const request = signedRequest(signingSecret, timestamp)

const response = await verify(request, { signingSecret }, `${rawBody} `)

expect(response?.status).toBe(401)
})

it("rejects an otherwise valid signature outside Slack's five-minute replay window", async () => {
const signingSecret = 'test-signing-secret'
const timestamp = String(Math.floor(Date.now() / 1000) - 301)

const response = await verify(signedRequest(signingSecret, timestamp), { signingSecret })

expect(response?.status).toBe(401)
})
})

describe('slackHandler formatInput - Events API', () => {
it('maps an app_mention event', async () => {
const { input } = await slackHandler.formatInput!(
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/lib/webhooks/providers/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -840,7 +840,8 @@ export const slackHandler: WebhookProviderHandler = {
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
const signingSecret = providerConfig.signingSecret as string | undefined
if (!signingSecret) {
return null
logger.warn(`[${requestId}] Slack webhook signing secret not configured`)
return new NextResponse('Unauthorized - Missing Slack signing secret', { status: 401 })
}
return verifySlackRequestSignature(signingSecret, request, rawBody, requestId)
},
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/lib/webhooks/slack-native-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { env } from '@/lib/core/config/env'

/** Returns the signing secret for the native Sim Slack app when it is configured. */
export function getSlackNativeSigningSecret(): string | null {
const signingSecret = env.SLACK_SIGNING_SECRET?.trim()
return signingSecret || null
}
Loading
Loading