From ed326c87a54f2efedd4a817c3dfdd1a66d77114e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 7 Apr 2026 17:46:04 -0700 Subject: [PATCH 1/2] feat(slack): add subtype field and signature verification to Slack trigger --- apps/sim/blocks/blocks/slack.ts | 13 ++++ apps/sim/lib/webhooks/providers/slack.ts | 77 ++++++++++++++++++++++++ apps/sim/triggers/slack/webhook.ts | 16 ++++- 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 70ece5a0e88..84d903c1754 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -1634,8 +1634,21 @@ Do not include any explanations, markdown formatting, or other text outside the // Trigger outputs (when used as webhook trigger) event_type: { type: 'string', description: 'Type of Slack event that triggered the workflow' }, + subtype: { + type: 'string', + description: + 'Message subtype (e.g., channel_join, channel_leave, bot_message). Null for regular user messages', + }, channel_name: { type: 'string', description: 'Human-readable channel name' }, + channel_type: { + type: 'string', + description: 'Type of channel (e.g., channel, group, im, mpim)', + }, user_name: { type: 'string', description: 'Username who triggered the event' }, + bot_id: { + type: 'string', + description: 'Bot ID if the message was sent by a bot. Null for human users', + }, timestamp: { type: 'string', description: 'Message timestamp from the triggering event' }, thread_ts: { type: 'string', diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 1bcedd628b9..602d51f7604 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -1,10 +1,13 @@ +import crypto from 'crypto' import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import type { + AuthContext, FormatInputContext, FormatInputResult, WebhookProviderHandler, @@ -177,6 +180,44 @@ async function fetchSlackMessageText( } } +/** Maximum allowed timestamp skew (5 minutes) per Slack docs. */ +const SLACK_TIMESTAMP_MAX_SKEW = 300 + +/** + * Validate Slack request signature using HMAC-SHA256. + * Basestring format: `v0:{timestamp}:{rawBody}` + * Signature header format: `v0={hex}` + */ +function validateSlackSignature( + signingSecret: string, + signature: string, + timestamp: string, + rawBody: string +): boolean { + try { + if (!signingSecret || !signature || !rawBody) { + return false + } + + if (!signature.startsWith('v0=')) { + logger.warn('Slack signature has invalid format (missing v0= prefix)') + return false + } + + const providedSignature = signature.substring(3) + const basestring = `v0:${timestamp}:${rawBody}` + const computedHash = crypto + .createHmac('sha256', signingSecret) + .update(basestring, 'utf8') + .digest('hex') + + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Slack signature:', error) + return false + } +} + /** * Handle Slack verification challenges */ @@ -190,6 +231,39 @@ export function handleSlackChallenge(body: unknown): NextResponse | null { } export const slackHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const signingSecret = providerConfig.signingSecret as string | undefined + if (!signingSecret) { + return null + } + + const signature = request.headers.get('x-slack-signature') + const timestamp = request.headers.get('x-slack-request-timestamp') + + if (!signature || !timestamp) { + logger.warn(`[${requestId}] Slack webhook missing signature or timestamp header`) + return new NextResponse('Unauthorized - Missing Slack signature', { status: 401 }) + } + + const now = Math.floor(Date.now() / 1000) + const skew = Math.abs(now - Number(timestamp)) + if (skew > SLACK_TIMESTAMP_MAX_SKEW) { + logger.warn(`[${requestId}] Slack webhook timestamp too old`, { + timestamp, + now, + skew, + }) + return new NextResponse('Unauthorized - Request timestamp too old', { status: 401 }) + } + + if (!validateSlackSignature(signingSecret, signature, timestamp, rawBody)) { + logger.warn(`[${requestId}] Slack signature verification failed`) + return new NextResponse('Unauthorized - Invalid Slack signature', { status: 401 }) + } + + return null + }, + handleChallenge(body: unknown) { return handleSlackChallenge(body) }, @@ -262,10 +336,13 @@ export const slackHandler: WebhookProviderHandler = { input: { event: { event_type: eventType, + subtype: (rawEvent?.subtype as string) || null, channel, channel_name: '', + channel_type: (rawEvent?.channel_type as string) || null, user: (rawEvent?.user as string) || '', user_name: '', + bot_id: (rawEvent?.bot_id as string) || null, text, timestamp: messageTs, thread_ts: (rawEvent?.thread_ts as string) || '', diff --git a/apps/sim/triggers/slack/webhook.ts b/apps/sim/triggers/slack/webhook.ts index 3f1bbe2c0f7..2fa8966ae63 100644 --- a/apps/sim/triggers/slack/webhook.ts +++ b/apps/sim/triggers/slack/webhook.ts @@ -68,7 +68,7 @@ export const slackWebhookTrigger: TriggerConfig = { 'If you don\'t have an app:
', 'Go to "Basic Information", find the "Signing Secret", and paste it in the field above.', 'Go to "OAuth & Permissions" and add bot token scopes:
', - 'Go to "Event Subscriptions":
', + 'Go to "Event Subscriptions":
', 'Go to "Install App" in the left sidebar and install the app into your desired Slack workspace and channel.', 'Copy the "Bot User OAuth Token" (starts with xoxb-) and paste it in the Bot Token field above to enable file downloads.', 'Save changes in both Slack and here.', @@ -92,6 +92,11 @@ export const slackWebhookTrigger: TriggerConfig = { type: 'string', description: 'Type of Slack event (e.g., app_mention, message)', }, + subtype: { + type: 'string', + description: + 'Message subtype (e.g., channel_join, channel_leave, bot_message, file_share). Null for regular user messages', + }, channel: { type: 'string', description: 'Slack channel ID where the event occurred', @@ -100,6 +105,11 @@ export const slackWebhookTrigger: TriggerConfig = { type: 'string', description: 'Human-readable channel name', }, + channel_type: { + type: 'string', + description: + 'Type of channel (e.g., channel, group, im, mpim). Useful for distinguishing DMs from public channels', + }, user: { type: 'string', description: 'User ID who triggered the event', @@ -108,6 +118,10 @@ export const slackWebhookTrigger: TriggerConfig = { type: 'string', description: 'Username who triggered the event', }, + bot_id: { + type: 'string', + description: 'Bot ID if the message was sent by a bot. Null for human users', + }, text: { type: 'string', description: 'Message text content', From d9219092ea5f05800727dd709fd9aba94cd5e3af Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 7 Apr 2026 17:58:34 -0700 Subject: [PATCH 2/2] fix(slack): guard against NaN timestamp and align null/empty-string convention --- apps/sim/lib/webhooks/providers/slack.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 602d51f7604..d645fd791ef 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -246,7 +246,12 @@ export const slackHandler: WebhookProviderHandler = { } const now = Math.floor(Date.now() / 1000) - const skew = Math.abs(now - Number(timestamp)) + const parsedTimestamp = Number(timestamp) + if (Number.isNaN(parsedTimestamp)) { + logger.warn(`[${requestId}] Slack webhook timestamp is not a valid number`, { timestamp }) + return new NextResponse('Unauthorized - Invalid timestamp', { status: 401 }) + } + const skew = Math.abs(now - parsedTimestamp) if (skew > SLACK_TIMESTAMP_MAX_SKEW) { logger.warn(`[${requestId}] Slack webhook timestamp too old`, { timestamp, @@ -336,13 +341,13 @@ export const slackHandler: WebhookProviderHandler = { input: { event: { event_type: eventType, - subtype: (rawEvent?.subtype as string) || null, + subtype: (rawEvent?.subtype as string) ?? '', channel, channel_name: '', - channel_type: (rawEvent?.channel_type as string) || null, + channel_type: (rawEvent?.channel_type as string) ?? '', user: (rawEvent?.user as string) || '', user_name: '', - bot_id: (rawEvent?.bot_id as string) || null, + bot_id: (rawEvent?.bot_id as string) ?? '', text, timestamp: messageTs, thread_ts: (rawEvent?.thread_ts as string) || '',