feat(slack): add subtype field and signature verification to Slack trigger (#4030)

* feat(slack): add subtype field and signature verification to Slack trigger

* fix(slack): guard against NaN timestamp and align null/empty-string convention
This commit is contained in:
Waleed
2026-04-07 18:13:26 -07:00
committed by GitHub
parent 0f602f79a4
commit e0f5cf880a
3 changed files with 110 additions and 1 deletions

View File

@@ -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',

View File

@@ -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,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)
},
@@ -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) || '',

View File

@@ -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.',
@@ -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',