diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index c8abb1b39..fa7ce1bdf 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -21,6 +21,7 @@ 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' @@ -74,8 +75,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 @@ -405,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 8b99f7dec..39371150c 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: string; 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.toString('base64'), + 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,44 @@ 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) + } else if (hasFiles && includeFiles && !botToken) { + logger.warn('Slack message has files and includeFiles is enabled, but no bot token provided') + } 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 4c9bd8990..3d22e3be2 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:
', '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 "OAuth & Permissions" and add bot token scopes:
', '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.', ] .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)', + }, }, }, },