diff --git a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx index fe9bc70bb4a..2e73a5e9de7 100644 --- a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx +++ b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx @@ -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). ({ - 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() }), @@ -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, })) @@ -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', @@ -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', diff --git a/apps/sim/app/api/webhooks/slack/route.ts b/apps/sim/app/api/webhooks/slack/route.ts index 80d06c7d9fb..49223d4c4ef 100644 --- a/apps/sim/app/api/webhooks/slack/route.ts +++ b/apps/sim/app/api/webhooks/slack/route.ts @@ -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') @@ -44,13 +44,7 @@ async function handleSlackAppWebhook(request: NextRequest): Promise // Route by the installed workspace(s). For Slack Connect the outer `team_id` diff --git a/apps/sim/lib/webhooks/deploy.test.ts b/apps/sim/lib/webhooks/deploy.test.ts index 471842702c1..b33e3669712 100644 --- a/apps/sim/lib/webhooks/deploy.test.ts +++ b/apps/sim/lib/webhooks/deploy.test.ts @@ -7,6 +7,8 @@ import { queueTableRows, resetDbChainMock, resetEnvFlagsMock, + resetEnvMock, + setEnv, setEnvFlags, } from '@sim/testing' import { eq, ne } from 'drizzle-orm' @@ -84,6 +86,7 @@ import { getTrigger } from '@/triggers' afterAll(() => { resetDbChainMock() + resetEnvMock() resetEnvFlagsMock() }) @@ -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 : {} @@ -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', @@ -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', diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index dbcedcdc7bd..e7fe9401ab7 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -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, @@ -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, diff --git a/apps/sim/lib/webhooks/providers/slack.test.ts b/apps/sim/lib/webhooks/providers/slack.test.ts index 26d57933fa9..aa071c4c0fd 100644 --- a/apps/sim/lib/webhooks/providers/slack.test.ts +++ b/apps/sim/lib/webhooks/providers/slack.test.ts @@ -1,3 +1,4 @@ +import { createHmac } from 'node:crypto' import { describe, expect, it } from 'vitest' import { handleSlackChallenge, @@ -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, 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!( diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 406181794b7..43a2627a715 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -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) }, diff --git a/apps/sim/lib/webhooks/slack-native-config.ts b/apps/sim/lib/webhooks/slack-native-config.ts new file mode 100644 index 00000000000..9acfd280eaa --- /dev/null +++ b/apps/sim/lib/webhooks/slack-native-config.ts @@ -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 +} diff --git a/packages/sim-setup/src/checks.test.ts b/packages/sim-setup/src/checks.test.ts new file mode 100644 index 00000000000..66bb5ac88f4 --- /dev/null +++ b/packages/sim-setup/src/checks.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { type CheckContext, runChecks } from './checks' +import type { EnvFile, EnvTarget } from './env-files' + +function envFile(target: EnvTarget, values: Record = {}): EnvFile { + return { + target, + path: `${target}.env`, + exists: target === 'root', + content: '', + vars: new Map(Object.entries(values)), + } +} + +function rootContext(values: Record): CheckContext { + const root = envFile('root', values) + return { + layout: 'root', + primary: root, + live: false, + env: { + root, + sim: envFile('sim'), + realtime: envFile('realtime'), + db: envFile('db'), + }, + } +} + +describe('setup coherence checks', () => { + it('requires a signing secret when native Slack triggers are enabled', async () => { + const findings = await runChecks( + rootContext({ + SLACK_EXTENDED_SCOPES: 'true', + NEXT_PUBLIC_SLACK_EXTENDED_SCOPES: 'true', + }), + ['coherence'] + ) + + expect(findings).toContainEqual({ + group: 'coherence', + status: 'fail', + message: + 'SLACK_EXTENDED_SCOPES is on but SLACK_SIGNING_SECRET is not set — native Slack triggers will fail at runtime', + fix: 'set SLACK_SIGNING_SECRET or remove SLACK_EXTENDED_SCOPES and NEXT_PUBLIC_SLACK_EXTENDED_SCOPES', + }) + }) + + it('treats a whitespace-only Slack signing secret as missing', async () => { + const findings = await runChecks( + rootContext({ + SLACK_EXTENDED_SCOPES: 'true', + NEXT_PUBLIC_SLACK_EXTENDED_SCOPES: 'true', + SLACK_SIGNING_SECRET: ' ', + }), + ['coherence'] + ) + + expect(findings).toContainEqual({ + group: 'coherence', + status: 'fail', + message: + 'SLACK_EXTENDED_SCOPES is on but SLACK_SIGNING_SECRET is not set — native Slack triggers will fail at runtime', + fix: 'set SLACK_SIGNING_SECRET or remove SLACK_EXTENDED_SCOPES and NEXT_PUBLIC_SLACK_EXTENDED_SCOPES', + }) + }) + + it('does not require a signing secret for outbound-only Slack OAuth', async () => { + const findings = await runChecks( + rootContext({ + SLACK_CLIENT_ID: 'client-id', + SLACK_CLIENT_SECRET: 'client-secret', + }), + ['coherence'] + ) + + expect(findings.some((finding) => finding.message.includes('SLACK_SIGNING_SECRET'))).toBe(false) + }) +}) diff --git a/packages/sim-setup/src/checks.ts b/packages/sim-setup/src/checks.ts index f85a48f87b8..c94eff4a51a 100644 --- a/packages/sim-setup/src/checks.ts +++ b/packages/sim-setup/src/checks.ts @@ -461,23 +461,34 @@ function checkCoherence(ctx: CheckContext): Finding[] { }) } - const featureRules: Array<{ flag: string; needs: string[]; label: string }> = [ + const featureRules: Array<{ + flag: string + needs: string[] + label: string + disableFields?: string[] + }> = [ { flag: 'BILLING_ENABLED', needs: ['STRIPE_SECRET_KEY'], label: 'billing', }, + { + flag: 'SLACK_EXTENDED_SCOPES', + needs: ['SLACK_SIGNING_SECRET'], + label: 'native Slack triggers', + disableFields: ['SLACK_EXTENDED_SCOPES', 'NEXT_PUBLIC_SLACK_EXTENDED_SCOPES'], + }, { flag: 'SSO_ENABLED', needs: ['SSO_ISSUER'], label: 'SSO' }, ] for (const rule of featureRules) { if (!isTruthy(sim.vars.get(rule.flag))) continue - const missing = rule.needs.filter((key) => !sim.vars.get(key)) + const missing = rule.needs.filter((key) => !sim.vars.get(key)?.trim()) if (missing.length > 0) { findings.push({ group: 'coherence', status: 'fail', message: `${rule.flag} is on but ${missing.join(', ')} is not set — ${rule.label} will fail at runtime`, - fix: `set ${missing.join(', ')} or remove ${rule.flag}`, + fix: `set ${missing.join(', ')} or remove ${(rule.disableFields ?? [rule.flag]).join(' and ')}`, }) } }