mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
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:
@@ -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',
|
||||
|
||||
@@ -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) || '',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user