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
13 changes: 13 additions & 0 deletions apps/sim/blocks/blocks/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
82 changes: 82 additions & 0 deletions apps/sim/lib/webhooks/providers/slack.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
*/
Expand All @@ -190,6 +231,44 @@ 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 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,
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)
},
Expand Down Expand Up @@ -262,10 +341,13 @@ export const slackHandler: WebhookProviderHandler = {
input: {
event: {
event_type: eventType,
subtype: (rawEvent?.subtype as string) ?? '',
channel,
channel_name: '',
channel_type: (rawEvent?.channel_type as string) ?? '',
user: (rawEvent?.user as string) || '',
user_name: '',
bot_id: (rawEvent?.bot_id as string) ?? '',
text,
timestamp: messageTs,
thread_ts: (rawEvent?.thread_ts as string) || '',
Expand Down
16 changes: 15 additions & 1 deletion apps/sim/triggers/slack/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export const slackWebhookTrigger: TriggerConfig = {
'If you don\'t have an app:<br><ul class="mt-1 ml-5 list-disc"><li>Create an app from scratch</li><li>Give it a name and select your workspace</li></ul>',
'Go to "Basic Information", find the "Signing Secret", and paste it in the field above.',
'Go to "OAuth & Permissions" and add bot token scopes:<br><ul class="mt-1 ml-5 list-disc"><li><code>app_mentions:read</code> - For viewing messages that tag your bot with an @</li><li><code>chat:write</code> - To send messages to channels your bot is a part of</li><li><code>files:read</code> - To access files and images shared in messages</li><li><code>reactions:read</code> - For listening to emoji reactions and fetching reacted-to message text</li></ul>',
'Go to "Event Subscriptions":<br><ul class="mt-1 ml-5 list-disc"><li>Enable events</li><li>Under "Subscribe to Bot Events", add <code>app_mention</code> to listen to messages that mention your bot</li><li>For reaction events, also add <code>reaction_added</code> and/or <code>reaction_removed</code></li><li>Paste the Webhook URL above into the "Request URL" field</li></ul>',
'Go to "Event Subscriptions":<br><ul class="mt-1 ml-5 list-disc"><li>Enable events</li><li>Under "Subscribe to Bot Events", add <code>app_mention</code> to listen to messages that mention your bot</li><li>To receive all channel messages, add <code>message.channels</code>. For DMs add <code>message.im</code>, for group DMs add <code>message.mpim</code>, for private channels add <code>message.groups</code></li><li>For reaction events, also add <code>reaction_added</code> and/or <code>reaction_removed</code></li><li>Paste the Webhook URL above into the "Request URL" field</li></ul>',
'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 <code>xoxb-</code>) and paste it in the Bot Token field above to enable file downloads.',
'Save changes in both Slack and here.',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down
Loading