From bce7d5a8a5fc52aa1a9bd68f81a1e0f1368b13e0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 5 Feb 2026 23:04:30 -0800 Subject: [PATCH 1/4] feat(slack): add file attachment support to slack webhook trigger --- apps/sim/background/webhook-execution.ts | 15 ++- apps/sim/lib/webhooks/utils.server.ts | 162 +++++++++++++++++++---- apps/sim/triggers/slack/webhook.ts | 33 ++++- 3 files changed, 180 insertions(+), 30 deletions(-) diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index c8abb1b396..4e5262e9fd 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -74,8 +74,21 @@ async function processTriggerFileOutputs( logger.error(`[${context.requestId}] Error processing ${currentPath}:`, error) processed[key] = val } + } else if ( + outputDef && + typeof outputDef === 'object' && + (outputDef.type === 'object' || outputDef.type === 'json') && + outputDef.properties + ) { + // Explicit object schema with properties - recurse into properties + processed[key] = await processTriggerFileOutputs( + val, + outputDef.properties, + context, + currentPath + ) } else if (outputDef && typeof outputDef === 'object' && !outputDef.type) { - // Nested object in schema - recurse with the nested schema + // Nested object in schema (flat pattern) - recurse with the nested schema processed[key] = await processTriggerFileOutputs(val, outputDef, context, currentPath) } else { // Not a file output - keep as is diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 8b99f7dec4..0346d818bc 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -527,6 +527,113 @@ export async function validateTwilioSignature( } } +const SLACK_FILE_HOSTS = new Set(['files.slack.com', 'files-pri.slack.com']) +const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB +const SLACK_MAX_FILES = 10 + +/** + * Downloads file attachments from Slack using the bot token. + * Returns files in the format expected by WebhookAttachmentProcessor: + * { name, data (base64 string), mimeType, size } + * + * Security: + * - Validates each url_private against allowlisted Slack file hosts + * - Uses validateUrlWithDNS + secureFetchWithPinnedIP to prevent SSRF + * - Enforces per-file size limit and max file count + */ +async function downloadSlackFiles( + rawFiles: any[], + botToken: string +): Promise> { + const filesToProcess = rawFiles.slice(0, SLACK_MAX_FILES) + const downloaded: Array<{ name: string; data: Buffer; mimeType: string; size: number }> = [] + + for (const file of filesToProcess) { + const urlPrivate = file.url_private as string | undefined + if (!urlPrivate) { + continue + } + + // Validate the URL points to a known Slack file host + let parsedUrl: URL + try { + parsedUrl = new URL(urlPrivate) + } catch { + logger.warn('Slack file has invalid url_private, skipping', { fileId: file.id }) + continue + } + + if (!SLACK_FILE_HOSTS.has(parsedUrl.hostname)) { + logger.warn('Slack file url_private points to unexpected host, skipping', { + fileId: file.id, + hostname: sanitizeUrlForLog(urlPrivate), + }) + continue + } + + // Skip files that exceed the size limit + const reportedSize = Number(file.size) || 0 + if (reportedSize > SLACK_MAX_FILE_SIZE) { + logger.warn('Slack file exceeds size limit, skipping', { + fileId: file.id, + size: reportedSize, + limit: SLACK_MAX_FILE_SIZE, + }) + continue + } + + try { + const urlValidation = await validateUrlWithDNS(urlPrivate, 'url_private') + if (!urlValidation.isValid) { + logger.warn('Slack file url_private failed DNS validation, skipping', { + fileId: file.id, + error: urlValidation.error, + }) + continue + } + + const response = await secureFetchWithPinnedIP(urlPrivate, urlValidation.resolvedIP!, { + headers: { Authorization: `Bearer ${botToken}` }, + }) + + if (!response.ok) { + logger.warn('Failed to download Slack file, skipping', { + fileId: file.id, + status: response.status, + }) + continue + } + + const arrayBuffer = await response.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + // Verify the actual downloaded size doesn't exceed our limit + if (buffer.length > SLACK_MAX_FILE_SIZE) { + logger.warn('Downloaded Slack file exceeds size limit, skipping', { + fileId: file.id, + actualSize: buffer.length, + limit: SLACK_MAX_FILE_SIZE, + }) + continue + } + + downloaded.push({ + name: file.name || 'download', + data: buffer, + mimeType: file.mimetype || 'application/octet-stream', + size: buffer.length, + }) + } catch (error) { + logger.error('Error downloading Slack file, skipping', { + fileId: file.id, + error: error instanceof Error ? error.message : String(error), + }) + } + } + + return downloaded +} + /** * Format webhook input based on provider */ @@ -787,43 +894,42 @@ export async function formatWebhookInput( } if (foundWebhook.provider === 'slack') { - const event = body?.event + const providerConfig = (foundWebhook.providerConfig as Record) || {} + const botToken = providerConfig.botToken as string | undefined + const includeFiles = Boolean(providerConfig.includeFiles) - if (event && body?.type === 'event_callback') { - return { - event: { - event_type: event.type || '', - channel: event.channel || '', - channel_name: '', - user: event.user || '', - user_name: '', - text: event.text || '', - timestamp: event.ts || event.event_ts || '', - thread_ts: event.thread_ts || '', - team_id: body.team_id || event.team || '', - event_id: body.event_id || '', - }, - } + const rawEvent = body?.event + + if (!rawEvent) { + logger.warn('Unknown Slack event type', { + type: body?.type, + hasEvent: false, + bodyKeys: Object.keys(body || {}), + }) } - logger.warn('Unknown Slack event type', { - type: body?.type, - hasEvent: !!body?.event, - bodyKeys: Object.keys(body || {}), - }) + const rawFiles: any[] = rawEvent?.files ?? [] + const hasFiles = rawFiles.length > 0 + + let files: any[] = [] + if (hasFiles && includeFiles && botToken) { + files = await downloadSlackFiles(rawFiles, botToken) + } return { event: { - event_type: body?.event?.type || body?.type || 'unknown', - channel: body?.event?.channel || '', + event_type: rawEvent?.type || body?.type || 'unknown', + channel: rawEvent?.channel || '', channel_name: '', - user: body?.event?.user || '', + user: rawEvent?.user || '', user_name: '', - text: body?.event?.text || '', - timestamp: body?.event?.ts || '', - thread_ts: body?.event?.thread_ts || '', - team_id: body?.team_id || '', + text: rawEvent?.text || '', + timestamp: rawEvent?.ts || rawEvent?.event_ts || '', + thread_ts: rawEvent?.thread_ts || '', + team_id: body?.team_id || rawEvent?.team || '', event_id: body?.event_id || '', + hasFiles, + files, }, } } diff --git a/apps/sim/triggers/slack/webhook.ts b/apps/sim/triggers/slack/webhook.ts index 4c9bd89909..3d22e3be20 100644 --- a/apps/sim/triggers/slack/webhook.ts +++ b/apps/sim/triggers/slack/webhook.ts @@ -30,6 +30,27 @@ export const slackWebhookTrigger: TriggerConfig = { required: true, mode: 'trigger', }, + { + id: 'botToken', + title: 'Bot Token', + type: 'short-input', + placeholder: 'xoxb-...', + description: + 'The bot token from your Slack app. Required for downloading files attached to messages.', + password: true, + required: false, + mode: 'trigger', + }, + { + id: 'includeFiles', + title: 'Include File Attachments', + type: 'switch', + defaultValue: false, + description: + 'Download and include file attachments from messages. Requires a bot token with files:read scope.', + required: false, + mode: 'trigger', + }, { id: 'triggerSave', title: '', @@ -46,9 +67,10 @@ export const slackWebhookTrigger: TriggerConfig = { 'Go to Slack Apps page', 'If you don\'t have an app:
  • Create an app from scratch
  • Give it a name and select your workspace
', 'Go to "Basic Information", find the "Signing Secret", and paste it in the field above.', - 'Go to "OAuth & Permissions" and add bot token scopes:
  • app_mentions:read - For viewing messages that tag your bot with an @
  • chat:write - To send messages to channels your bot is a part of
', + 'Go to "OAuth & Permissions" and add bot token scopes:
  • app_mentions:read - For viewing messages that tag your bot with an @
  • chat:write - To send messages to channels your bot is a part of
  • files:read - To access files and images shared in messages
', 'Go to "Event Subscriptions":
  • Enable events
  • Under "Subscribe to Bot Events", add app_mention to listen to messages that mention your bot
  • Paste the Webhook URL above into the "Request URL" field
', '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.', ] .map( @@ -106,6 +128,15 @@ export const slackWebhookTrigger: TriggerConfig = { type: 'string', description: 'Unique event identifier', }, + hasFiles: { + type: 'boolean', + description: 'Whether the message has file attachments', + }, + files: { + type: 'file[]', + description: + 'File attachments downloaded from the message (if includeFiles is enabled and bot token is provided)', + }, }, }, }, From d7526c5ad22528947f9f9fc281cbe1a0f5a6a149 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 6 Feb 2026 00:09:03 -0800 Subject: [PATCH 2/4] additional file handling --- apps/sim/background/webhook-execution.ts | 15 ++++++++++++++- apps/sim/lib/webhooks/utils.server.ts | 6 +++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index 4e5262e9fd..4845cb0202 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -24,6 +24,7 @@ import { getWorkflowById } from '@/lib/workflows/utils' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionMetadata } from '@/executor/execution/types' import { hasExecutionResult } from '@/executor/utils/errors' +import { getBlock } from '@/blocks' import { safeAssign } from '@/tools/safe-assign' import { getTrigger, isTriggerValid } from '@/triggers' @@ -418,11 +419,23 @@ async function executeWebhookJobInternal( const rawSelectedTriggerId = triggerBlock?.subBlocks?.selectedTriggerId?.value const rawTriggerId = triggerBlock?.subBlocks?.triggerId?.value - const resolvedTriggerId = [rawSelectedTriggerId, rawTriggerId].find( + let resolvedTriggerId = [rawSelectedTriggerId, rawTriggerId].find( (candidate): candidate is string => typeof candidate === 'string' && isTriggerValid(candidate) ) + if (!resolvedTriggerId) { + const blockConfig = getBlock(triggerBlock.type) + if (blockConfig?.category === 'triggers' && isTriggerValid(triggerBlock.type)) { + resolvedTriggerId = triggerBlock.type + } else if (triggerBlock.triggerMode && blockConfig?.triggers?.enabled) { + const available = blockConfig.triggers?.available?.[0] + if (available && isTriggerValid(available)) { + resolvedTriggerId = available + } + } + } + if (resolvedTriggerId) { const triggerConfig = getTrigger(resolvedTriggerId) diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 0346d818bc..55007def9c 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -544,9 +544,9 @@ const SLACK_MAX_FILES = 10 async function downloadSlackFiles( rawFiles: any[], botToken: string -): Promise> { +): Promise> { const filesToProcess = rawFiles.slice(0, SLACK_MAX_FILES) - const downloaded: Array<{ name: string; data: Buffer; mimeType: string; size: number }> = [] + const downloaded: Array<{ name: string; data: string; mimeType: string; size: number }> = [] for (const file of filesToProcess) { const urlPrivate = file.url_private as string | undefined @@ -619,7 +619,7 @@ async function downloadSlackFiles( downloaded.push({ name: file.name || 'download', - data: buffer, + data: buffer.toString('base64'), mimeType: file.mimetype || 'application/octet-stream', size: buffer.length, }) From 3617476f6c773a57ea2ba7e2333e907274fc7eab Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 6 Feb 2026 00:12:36 -0800 Subject: [PATCH 3/4] lint --- apps/sim/background/webhook-execution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index 4845cb0202..fa7ce1bdfe 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -21,10 +21,10 @@ import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' import { getWorkflowById } from '@/lib/workflows/utils' +import { getBlock } from '@/blocks' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionMetadata } from '@/executor/execution/types' import { hasExecutionResult } from '@/executor/utils/errors' -import { getBlock } from '@/blocks' import { safeAssign } from '@/tools/safe-assign' import { getTrigger, isTriggerValid } from '@/triggers' From a59f113b13d7d499255d540790aac64d145aa44e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 6 Feb 2026 00:23:59 -0800 Subject: [PATCH 4/4] ack comment --- apps/sim/lib/webhooks/utils.server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 55007def9c..39371150c8 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -914,6 +914,8 @@ export async function formatWebhookInput( let files: any[] = [] if (hasFiles && includeFiles && botToken) { files = await downloadSlackFiles(rawFiles, botToken) + } else if (hasFiles && includeFiles && !botToken) { + logger.warn('Slack message has files and includeFiles is enabled, but no bot token provided') } return {