diff --git a/sim/app/api/webhooks/test/route.ts b/sim/app/api/webhooks/test/route.ts index 3f52bbc141..8e71485267 100644 --- a/sim/app/api/webhooks/test/route.ts +++ b/sim/app/api/webhooks/test/route.ts @@ -224,6 +224,55 @@ export async function GET(request: NextRequest) { }) } + case 'slack': { + const signingSecret = providerConfig.signingSecret + + if (!signingSecret) { + logger.warn(`[${requestId}] Slack webhook missing signing secret: ${webhookId}`) + return NextResponse.json( + { success: false, error: 'Webhook has no signing secret configured' }, + { status: 400 } + ) + } + + logger.info(`[${requestId}] Slack webhook test successful: ${webhookId}`) + return NextResponse.json({ + success: true, + webhook: { + id: foundWebhook.id, + url: webhookUrl, + isActive: foundWebhook.isActive, + }, + message: + 'Slack webhook configuration is valid. Use this URL in your Slack Event Subscriptions settings.', + setup: { + url: webhookUrl, + events: ['message.channels', 'reaction_added', 'app_mention'], + signingSecretConfigured: true, + }, + test: { + curlCommand: [ + `curl -X POST "${webhookUrl}"`, + `-H "Content-Type: application/json"`, + `-H "X-Slack-Request-Timestamp: $(date +%s)"`, + `-H "X-Slack-Signature: v0=$(date +%s)"`, + `-d '{"type":"event_callback","event":{"type":"message","channel":"C0123456789","user":"U0123456789","text":"Hello from Slack!","ts":"1234567890.123456"},"team_id":"T0123456789"}'` + ].join(' \\\n'), + samplePayload: { + "type": "event_callback", + "token": "XXYYZZ", + "team_id": "T123ABC", + "event": { + "type": "message", + "user": "U123ABC", + "text": "Hello from Slack!", + "ts": "1234567890.1234" + }, + "event_id": "Ev123ABC" + } + } + }) + } default: { // Generic webhook test logger.info(`[${requestId}] Generic webhook test successful: ${webhookId}`) diff --git a/sim/app/api/webhooks/trigger/[path]/route.ts b/sim/app/api/webhooks/trigger/[path]/route.ts index 156f6167ca..3f5a0bf8d1 100644 --- a/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/sim/app/api/webhooks/trigger/[path]/route.ts @@ -12,6 +12,7 @@ import { db } from '@/db' import { environment, userStats, webhook, workflow } from '@/db/schema' import { Executor } from '@/executor' import { Serializer } from '@/serializer' +import {validateSlackSignature} from '../../utils' const logger = createLogger('WebhookTriggerAPI') @@ -113,12 +114,18 @@ export async function POST( const requestId = crypto.randomUUID().slice(0, 8) const executionId = uuidv4() let foundWorkflow: any = null + let rawBody: string | null = null try { const path = (await params).path - + + // Clone the request to get both the raw body for Slack signature verification + // and the parsed JSON body for processing + const requestClone = request.clone() + rawBody = await requestClone.text() + // Parse the request body - const body = await request.json().catch(() => ({})) + const body = JSON.parse(rawBody || '{}') logger.info(`[${requestId}] Webhook POST request received for path: ${path}`) // Generate a unique request ID based on the request content @@ -156,8 +163,68 @@ export async function POST( workflowId: foundWorkflow.id, }) - // For WhatsApp, also check for duplicate messages using their message ID - if (foundWebhook.provider === 'whatsapp') { + // Provider-specific validation and handling + if (foundWebhook.provider === 'slack') { + // Validate Slack signature if this is a Slack webhook + const providerConfig = (foundWebhook.providerConfig as Record) || {} + const signingSecret = providerConfig.signingSecret + + if (signingSecret) { + const slackSignature = request.headers.get('x-slack-signature') + const slackTimestamp = request.headers.get('x-slack-request-timestamp') + + if (!slackSignature || !slackTimestamp || !rawBody) { + logger.warn(`[${requestId}] Missing Slack signature headers`, { + hasSignature: !!slackSignature, + hasTimestamp: !!slackTimestamp, + hasBody: !!rawBody + }) + return NextResponse.json({ error: 'Invalid Slack request' }, { status: 400 }) + } + + // Validate the Slack signature + const isValid = await validateSlackSignature( + signingSecret, + slackSignature, + slackTimestamp, + rawBody + ) + + if (!isValid) { + logger.warn(`[${requestId}] Invalid Slack signature`) + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }) + } + + logger.info(`[${requestId}] Slack signature validated successfully`) + + // Handle Slack URL verification challenge during POST + if (body.type === 'url_verification' && body.challenge) { + logger.info(`[${requestId}] Responding to Slack URL verification challenge`) + return NextResponse.json({ challenge: body.challenge }) + } + } + + // Check if we've already processed this message using Redis + const messageId = body?.event?.event_id + if (messageId && (await hasProcessedMessage(messageId))) { + logger.info(`[${requestId}] Duplicate Slack message detected with ID: ${messageId}`) + // Return early for duplicate messages to prevent workflow execution + return new NextResponse('Duplicate message', { status: 200 }) + } + + // Store the message ID in Redis to prevent duplicate processing in future requests + if (messageId) { + await markMessageAsProcessed(messageId) + } + + // Mark this request as processed to prevent duplicates + await markMessageAsProcessed(requestHash, 60 * 60 * 24) + + // Process the webhook for Slack + return await processWebhook(foundWebhook, foundWorkflow, body, request, executionId, requestId) + + } else if (foundWebhook.provider === 'whatsapp') { + // Extract WhatsApp specific data const data = body?.entry?.[0]?.changes?.[0]?.value const messages = data?.messages || [] diff --git a/sim/app/api/webhooks/utils.ts b/sim/app/api/webhooks/utils.ts new file mode 100644 index 0000000000..61dc6d570e --- /dev/null +++ b/sim/app/api/webhooks/utils.ts @@ -0,0 +1,70 @@ +/** + * Validates a Slack webhook request signature using HMAC SHA-256 + * @param signingSecret - Slack signing secret for validation + * @param signature - X-Slack-Signature header value + * @param timestamp - X-Slack-Request-Timestamp header value + * @param body - Raw request body string + * @returns Whether the signature is valid + */ + +export async function validateSlackSignature( + signingSecret: string, + signature: string, + timestamp: string, + body: string + ): Promise { + try { + // Basic validation first + if (!signingSecret || !signature || !timestamp || !body) { + return false + } + + // Check if the timestamp is too old (> 5 minutes) + const currentTime = Math.floor(Date.now() / 1000) + if (Math.abs(currentTime - parseInt(timestamp)) > 300) { + return false + } + + // Compute the signature + const encoder = new TextEncoder() + const baseString = `v0:${timestamp}:${body}` + + // Create the HMAC with the signing secret + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(signingSecret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ) + + const signatureBytes = await crypto.subtle.sign( + 'HMAC', + key, + encoder.encode(baseString) + ) + + // Convert the signature to hex + const signatureHex = Array.from(new Uint8Array(signatureBytes)) + .map(b => b.toString(16).padStart(2, '0')) + .join('') + + // Prepare the expected signature format + const computedSignature = `v0=${signatureHex}` + + // Constant-time comparison to prevent timing attacks + if (computedSignature.length !== signature.length) { + return false + } + + let result = 0 + for (let i = 0; i < computedSignature.length; i++) { + result |= computedSignature.charCodeAt(i) ^ signature.charCodeAt(i) + } + + return result === 0 + } catch (error) { + console.error('Error validating Slack signature:', error) + return false + } + } \ No newline at end of file diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack-config.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack-config.tsx new file mode 100644 index 0000000000..155c5742ae --- /dev/null +++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack-config.tsx @@ -0,0 +1,119 @@ +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { CopyableField } from '../ui/copyable' +import { TestResultDisplay } from '../ui/test-result' + +interface SlackConfigProps { + signingSecret: string + setSigningSecret: (secret: string) => void + isLoadingToken: boolean + testResult: { + success: boolean + message?: string + test?: any + } | null + copied: string | null + copyToClipboard: (text: string, type: string) => void + testWebhook: () => Promise + +} + +export function SlackConfig({ + signingSecret, + setSigningSecret, + isLoadingToken, + testResult, + copied, + copyToClipboard, +}: SlackConfigProps) { + return ( +
+
+ + +

+ The signing secret is provided in your Slack app's Basic Information page. +

+
+ + + +
+

Setup Instructions

+
    +
  1. Go to your Slack Apps page
  2. +
  3. Create a new app or select an existing one
  4. +
  5. Navigate to "Event Subscriptions" in the left sidebar
  6. +
  7. Enable events and add the Webhook URL above as the Request URL
  8. +
  9. Add the event subscriptions you want to receive (e.g., message.channels)
  10. +
  11. Go to "Basic Information" and copy your Signing Secret
  12. +
  13. Paste the Signing Secret in the field above
  14. +
  15. Save your configuration
  16. +
+
+ +
+
+ Slack Webhook Features +
+
    +
  • + • + + Receive events from Slack channels, direct messages, and more + +
  • +
  • + • + + Trigger workflows based on messages, reactions, or other Slack events + +
  • +
  • + • + + Securely verify incoming requests with Slack's signing secret + +
  • +
+
+ +
+
+ Example Slack Event +
+
+          {`{
+  "type": "event_callback",
+  "event": {
+    "type": "message",
+    "channel": "C0123456789",
+    "user": "U0123456789",
+    "text": "Hello from Slack!",
+    "ts": "1234567890.123456"
+  },
+  "team_id": "T0123456789",
+  "event_id": "Ev0123456789",
+  "event_time": 1234567890
+}`}
+        
+
+
+ ) +} diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-footer.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-footer.tsx index a09ecb3a9d..e4b38aaa02 100644 --- a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-footer.tsx +++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/ui/webhook-footer.tsx @@ -28,7 +28,7 @@ export function WebhookDialogFooter({ onClose, }: WebhookDialogFooterProps) { const showTestButton = - webhookId && (webhookProvider === 'whatsapp' || webhookProvider === 'generic') && onTest + webhookId && (webhookProvider === 'whatsapp' || webhookProvider === 'generic' || webhookProvider === "slack") && onTest return ( diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx index 5f26f8112b..2f9ccb2c14 100644 --- a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx +++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx @@ -12,6 +12,7 @@ import { UnsavedChangesDialog } from './ui/confirmation' import { WebhookDialogFooter } from './ui/webhook-footer' import { WebhookDialogHeader } from './ui/webhook-header' import { WebhookUrlField } from './ui/webhook-url' +import { SlackConfig } from './providers/slack-config' const logger = createLogger('WebhookModal') @@ -69,6 +70,7 @@ export function WebhookModal({ const [githubContentType, setGithubContentType] = useState('application/json') const [discordWebhookName, setDiscordWebhookName] = useState('') const [discordAvatarUrl, setDiscordAvatarUrl] = useState('') + const [slackSigningSecret, setSlackSigningSecret] = useState('') // Original values to track changes const [originalValues, setOriginalValues] = useState({ @@ -80,6 +82,7 @@ export function WebhookModal({ allowedIps: '', discordWebhookName: '', discordAvatarUrl: '', + slackSigningSecret: '', }) // Get the current provider configuration @@ -174,6 +177,10 @@ export function WebhookModal({ requireAuth: auth, allowedIps: ips, })) + } else if (webhookProvider === 'slack' && 'signingSecret' in config) { + const signingSecret = config.signingSecret || '' + setSlackSigningSecret(signingSecret) + setOriginalValues((prev) => ({ ...prev, slackSigningSecret: signingSecret })) } } } @@ -205,7 +212,9 @@ export function WebhookModal({ (generalToken !== originalValues.generalToken || secretHeaderName !== originalValues.secretHeaderName || requireAuth !== originalValues.requireAuth || - allowedIps !== originalValues.allowedIps)) + allowedIps !== originalValues.allowedIps)) || + (webhookProvider === 'slack' && + slackSigningSecret !== originalValues.slackSigningSecret) setHasUnsavedChanges(hasChanges) }, [ @@ -219,6 +228,7 @@ export function WebhookModal({ requireAuth, allowedIps, originalValues, + slackSigningSecret, ]) // Use the provided path or generate a UUID-based path @@ -266,6 +276,8 @@ export function WebhookModal({ requireAuth, allowedIps: parsedIps.length > 0 ? parsedIps : undefined, } + case 'slack': + return { signingSecret: slackSigningSecret } default: return {} } @@ -296,6 +308,7 @@ export function WebhookModal({ allowedIps, discordWebhookName, discordAvatarUrl, + slackSigningSecret, }) setHasUnsavedChanges(false) } @@ -435,6 +448,18 @@ export function WebhookModal({ copyToClipboard={copyToClipboard} /> ) + case 'slack': + return ( + + ) case 'generic': default: return ( diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/webhook-config.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/webhook-config.tsx index 9dac1c03e5..34e3861bda 100644 --- a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/webhook-config.tsx +++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/webhook-config.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useParams } from 'next/navigation' import { CheckCircle2, ExternalLink } from 'lucide-react' -import { DiscordIcon, GithubIcon, StripeIcon, WhatsAppIcon } from '@/components/icons' +import { DiscordIcon, GithubIcon, StripeIcon, WhatsAppIcon, SlackIcon } from '@/components/icons' import { Button } from '@/components/ui/button' import { createLogger } from '@/lib/logs/console-logger' import { useSubBlockValue } from '../../hooks/use-sub-block-value' @@ -50,6 +50,10 @@ export interface GeneralWebhookConfig { allowedIps?: string[] } +export interface SlackConfig { + signingSecret: string +} + // Union type for all provider configurations export type ProviderConfig = | WhatsAppConfig @@ -57,6 +61,7 @@ export type ProviderConfig = | DiscordConfig | StripeConfig | GeneralWebhookConfig + | SlackConfig | Record // Define available webhook providers @@ -145,6 +150,19 @@ export const WEBHOOK_PROVIDERS: { [key: string]: WebhookProvider } = { }, }, }, + slack: { + id: 'slack', + name: 'Slack', + icon: (props) => , + configFields: { + signingSecret: { + type: 'string', + label: 'Signing Secret', + placeholder: 'Enter your Slack app signing secret', + description: 'The signing secret from your Slack app to validate request authenticity.', + }, + }, + }, } interface WebhookConfigProps { diff --git a/sim/blocks/blocks/starter.ts b/sim/blocks/blocks/starter.ts index 00734d6183..8deda94c22 100644 --- a/sim/blocks/blocks/starter.ts +++ b/sim/blocks/blocks/starter.ts @@ -42,6 +42,7 @@ export const StarterBlock: BlockConfig = { { label: 'WhatsApp', id: 'whatsapp' }, { label: 'GitHub', id: 'github' }, { label: 'Discord', id: 'discord' }, + { label: 'Slack', id: 'slack' }, // { label: 'Stripe', id: 'stripe' }, ], value: () => 'generic',