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
30 changes: 28 additions & 2 deletions apps/sim/background/webhook-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
164 changes: 136 additions & 28 deletions apps/sim/lib/webhooks/utils.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<{ name: string; data: string; mimeType: string; size: number }>> {
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
*/
Expand Down Expand Up @@ -787,43 +894,44 @@ export async function formatWebhookInput(
}

if (foundWebhook.provider === 'slack') {
const event = body?.event
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
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,
},
}
}
Expand Down
33 changes: 32 additions & 1 deletion apps/sim/triggers/slack/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand All @@ -46,9 +67,10 @@ export const slackWebhookTrigger: TriggerConfig = {
'Go to <a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">Slack Apps page</a>',
'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></ul>',
'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></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>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.',
]
.map(
Expand Down Expand Up @@ -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)',
},
},
},
},
Expand Down