From f54f7e003f52144be325f5a8dc40c108c4e4487f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 15:06:40 -0700 Subject: [PATCH] Harden workflow sanitization and Slack setup --- .../connect-slack-bot-modal.tsx | 12 ++--- .../workflow/edit-workflow/validation.ts | 17 ++++++- apps/sim/lib/copilot/vfs/serializers.ts | 45 +++++++++++++++++++ .../sanitization/json-sanitizer.test.ts | 44 +++++++++++++++++- .../workflows/sanitization/json-sanitizer.ts | 37 ++++++++++++++- apps/sim/triggers/constants.ts | 10 +++++ apps/sim/triggers/webhook-url.ts | 10 +++++ 7 files changed, 163 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx index ba923cc1ff..31989db237 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx @@ -17,13 +17,13 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { SlackIcon } from '@/components/icons' -import { getBaseUrl } from '@/lib/core/utils/urls' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { useCreateWorkspaceCredential, useUpdateWorkspaceCredential, } from '@/hooks/queries/credentials' import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities' +import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url' const logger = createLogger('ConnectSlackBotModal') @@ -109,13 +109,9 @@ export function ConnectSlackBotModal({ } }, [open, created, isReconnect, initialDisplayName, initialDescription]) - // NEXT_PUBLIC_APP_URL, not window.location.origin: Slack's servers must be - // able to reach this URL, so it has to be the app's public base (e.g. the - // tunnel host in dev), not whatever host the browser happens to be on. - const requestUrl = useMemo( - () => `${getBaseUrl()}/api/webhooks/slack/custom/${credentialId}`, - [credentialId] - ) + // Shared server-side derivation: uses the app public base (not + // window.location.origin) so Slack's servers can reach it. + const requestUrl = useMemo(() => buildSlackCustomBotRequestUrl(credentialId), [credentialId]) const manifestJson = useMemo(() => { const manifest = buildSlackManifest(selected, { diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index 2780a90249..d53f5ddc20 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -22,7 +22,11 @@ import { BlockType, EDGE, normalizeName } from '@/executor/constants' import { isAutoModel, isKnownModelId, suggestModelIdsForUnknownModel } from '@/providers/models' import { isPiByokOnlyMode } from '@/providers/pi-providers' import { getTool } from '@/tools/utils' -import { TRIGGER_RUNTIME_SUBBLOCK_IDS, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' +import { + TRIGGER_ROUTING_FIELD, + TRIGGER_RUNTIME_SUBBLOCK_IDS, + TRIGGER_WEBHOOK_URL_FIELD, +} from '@/triggers/constants' import type { EdgeHandleValidationResult, EditWorkflowOperation, @@ -75,6 +79,17 @@ export function validateInputsForBlock( inputs = omit(inputs, [TRIGGER_WEBHOOK_URL_FIELD]) } + if (TRIGGER_ROUTING_FIELD in inputs) { + errors.push({ + blockId, + blockType, + field: TRIGGER_ROUTING_FIELD, + value: inputs[TRIGGER_ROUTING_FIELD], + error: `"${TRIGGER_ROUTING_FIELD}" is read-only. Event routing is derived from the selected credential and cannot be edited on the block.`, + }) + inputs = omit(inputs, [TRIGGER_ROUTING_FIELD]) + } + const blockConfig = getBlock(blockType) if (!blockConfig) { diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index cb5348a061..5ad229b53d 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -12,6 +12,7 @@ import { SANDBOX_SELECTABLE_CLI_TOOL_IDS, } from '@/lib/execution/remote-sandbox/cli-tools' import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks' @@ -24,6 +25,8 @@ import { SIM_AUTO_MODEL_ID, } from '@/providers/models' import type { ToolConfig, ToolHostingCondition } from '@/tools/types' +import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities' +import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url' /** The service-account alternative to OAuth for a service, when it offers one. */ export interface VfsServiceAccountAuth { @@ -733,6 +736,15 @@ export function serializeCredentials( // credential) — they reconnect differently, so the agent must branch on // this. Env-var credentials carry no type. type: a.credentialType, + // Derived, not stored: the public Request URL a Slack custom-bot app + // posts events to. One per credential; every workflow trigger that + // selects this credential shares it. This is what the setup wizard shows + // in Slack's Event Subscriptions step. + ...(a.credentialType === 'service_account' && + a.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID && + a.id + ? { requestUrl: buildSlackCustomBotRequestUrl(a.id) } + : {}), connectedAt: a.createdAt.toISOString(), })), null, @@ -1137,6 +1149,38 @@ export function serializeIntegrationSchema( ) } +/** + * Derived setup reference for `slack_oauth` — the same material the custom-bot + * setup wizard shows, surfaced so the copilot can walk a user (or the browser + * agent) through Slack app creation without guessing. None of this is a block + * field: the manifest is a template for api.slack.com, and the Request URL is a + * per-credential property (`requestUrl` in environment/credentials.json). + */ +function slackOAuthSetupReference(): Record { + const defaults = SLACK_CAPABILITIES.filter((c) => c.defaultChecked).map((c) => c.id) + return { + note: + 'Setup reference (derived; NOT block fields). A custom bot is a reusable workspace credential: ' + + 'one Slack app, one Request URL, shared by every trigger that selects it. To create or rotate one, ' + + 'emit a service_account credential card for provider "slack" — the wizard collects the signing secret ' + + 'and bot token without them entering the chat. Existing custom bots appear as service_account ' + + 'credentials in environment/credentials.json, each with its requestUrl.', + requestUrlPattern: '{baseUrl}/api/webhooks/slack/custom/{credentialId}', + capabilities: SLACK_CAPABILITIES.map((c) => ({ + id: c.id, + label: c.label, + group: c.group, + defaultChecked: c.defaultChecked, + scopes: c.scopes, + events: c.events, + })), + defaultManifest: buildSlackManifest(new Set(defaults), { + appName: 'Sim Bot', + webhookUrl: '', + }), + } +} + /** * Serialize a trigger schema for VFS components/triggers/{provider}/{id}.json */ @@ -1160,6 +1204,7 @@ export function serializeTriggerSchema(trigger: { webhook: trigger.webhook || undefined, subBlocks: trigger.subBlocks.map(serializeSubBlock), outputs: trigger.outputs, + ...(trigger.id === 'slack_oauth' ? { setup: slackOAuthSetupReference() } : {}), }, null, 2 diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts index 4ac13e4a49..2e8ff1b59d 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts @@ -5,7 +5,7 @@ import { resetUrlsMock, urlsMockFns } from '@sim/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' +import { TRIGGER_ROUTING_FIELD, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' beforeAll(() => { urlsMockFns.mockGetBaseUrl.mockReturnValue('https://sim.test') @@ -282,3 +282,45 @@ describe('sanitizeForCopilot webhook trigger URL', () => { expect(result.blocks['gh-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_WEBHOOK_URL_FIELD) }) }) + +describe('sanitizeForCopilot credential-routed trigger routing', () => { + it('synthesizes the read-only routing note for a slack_v2 block in trigger mode', () => { + const result = sanitizeForCopilot( + makeSingleBlockWorkflow('slack-1', { + type: 'slack_v2', + name: 'Slack Trigger', + enabled: true, + triggerMode: true, + subBlocks: { + selectedTriggerId: { id: 'selectedTriggerId', type: 'short-input', value: 'slack_oauth' }, + customBotCredential: { + id: 'customBotCredential', + type: 'oauth-input', + value: 'cred-123', + }, + }, + }) + ) + + const routing = result.blocks['slack-1'].inputs?.[TRIGGER_ROUTING_FIELD] as + | Record + | undefined + expect(routing?.model).toBe('credential-routed') + expect(routing?.selectedCredentialId).toBe('cred-123') + expect(String(routing?.note)).toContain('no per-workflow webhook URL') + expect(result.blocks['slack-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_WEBHOOK_URL_FIELD) + }) + + it('omits the routing note when the block is not in trigger mode', () => { + const result = sanitizeForCopilot( + makeSingleBlockWorkflow('slack-1', { + type: 'slack_v2', + name: 'Slack Action', + enabled: true, + subBlocks: {}, + }) + ) + + expect(result.blocks['slack-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_ROUTING_FIELD) + }) +}) diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index 5a63706a44..bd4f2377b1 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -12,8 +12,8 @@ import type { WorkflowState, } from '@/stores/workflows/workflow/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' -import { blockAdvertisesWebhookUrl } from '@/triggers/webhook-url' +import { TRIGGER_ROUTING_FIELD, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' +import { blockAdvertisesWebhookUrl, resolveBlockTriggerId } from '@/triggers/webhook-url' /** * Sanitized workflow state for copilot (removes all UI-specific data) @@ -364,6 +364,35 @@ function resolveTriggerWebhookUrl(blockId: string, block: BlockState): string | } } +/** Trigger ids that deliver by credential routing — no per-workflow URL exists. */ +const CREDENTIAL_ROUTED_TRIGGER_IDS = new Set(['slack_oauth']) + +/** + * Derived routing note for trigger blocks that have NO per-workflow webhook URL + * (credential-routed delivery, e.g. Slack v2's `slack_oauth`). Mirrors what the + * setup wizard shows: events arrive at the selected credential's endpoint — a + * custom bot's per-credential Request URL (surfaced as `requestUrl` on that + * credential in environment/credentials.json) or the shared Sim-app endpoint + * routed by Slack workspace. Surfaced as the read-only + * {@link TRIGGER_ROUTING_FIELD} input; rejected on write by `edit_workflow`. + */ +function resolveTriggerRouting(block: BlockState): Record | null { + const triggerId = resolveBlockTriggerId(block) + if (!triggerId || !CREDENTIAL_ROUTED_TRIGGER_IDS.has(triggerId)) return null + const selected = + block.subBlocks?.customBotCredential?.value ?? block.subBlocks?.manualBotCredential?.value + const selectedCredentialId = typeof selected === 'string' && selected.length > 0 ? selected : null + return { + model: 'credential-routed', + note: + 'This trigger has no per-workflow webhook URL. Events are delivered via the selected Slack credential: ' + + 'a custom bot posts to its per-credential Request URL (the requestUrl field on that credential in ' + + 'environment/credentials.json — the same URL the setup wizard shows for Slack Event Subscriptions); ' + + 'a Sim-app connection routes by Slack workspace automatically. Derived at read time; not an editable field.', + ...(selectedCredentialId ? { selectedCredentialId } : {}), + } +} + /** * Convert internal condition handle (condition-{uuid}) to simple format (if, else-if-0, else) * Uses 0-indexed numbering for else-if conditions @@ -587,6 +616,10 @@ export function sanitizeForCopilot( if (webhookUrl) { inputs[TRIGGER_WEBHOOK_URL_FIELD] = webhookUrl } + const triggerRouting = resolveTriggerRouting(block) + if (triggerRouting) { + inputs[TRIGGER_ROUTING_FIELD] = triggerRouting + } } // Check if this is a loop or parallel (has children) diff --git a/apps/sim/triggers/constants.ts b/apps/sim/triggers/constants.ts index 64ed9a898a..94f2cf9c38 100644 --- a/apps/sim/triggers/constants.ts +++ b/apps/sim/triggers/constants.ts @@ -40,6 +40,16 @@ export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [ */ export const TRIGGER_WEBHOOK_URL_FIELD = 'triggerWebhookUrl' +/** + * Derived, read-only input surfaced on copilot reads of trigger blocks that + * route by CREDENTIAL rather than a per-workflow webhook URL (e.g. Slack v2's + * `slack_oauth`). Explains where events actually arrive — a custom bot's + * per-credential Request URL or the shared Sim-app endpoint — so the copilot + * can answer "where do I point Slack?" without inventing a field. Never + * stored; rejected on write like {@link TRIGGER_WEBHOOK_URL_FIELD}. + */ +export const TRIGGER_ROUTING_FIELD = 'triggerRouting' + /** * Maximum number of consecutive failures before a trigger (schedule/webhook) is auto-disabled. * This prevents runaway errors from continuously executing failing workflows. diff --git a/apps/sim/triggers/webhook-url.ts b/apps/sim/triggers/webhook-url.ts index 289e928a9c..6f28289f08 100644 --- a/apps/sim/triggers/webhook-url.ts +++ b/apps/sim/triggers/webhook-url.ts @@ -12,6 +12,16 @@ export function buildWebhookTriggerUrl(path: string): string { return `${getBaseUrl()}/api/webhooks/trigger/${path}` } +/** + * The Request URL a Slack custom-bot app posts events to. One URL per + * credential (not per workflow): the endpoint verifies with the credential's + * signing secret and fans out to every workflow whose trigger routes by this + * credential id. Uses the app's public base so Slack's servers can reach it. + */ +export function buildSlackCustomBotRequestUrl(credentialId: string): string { + return `${getBaseUrl()}/api/webhooks/slack/custom/${credentialId}` +} + function subBlockValue(block: BlockState, subBlockId: string): unknown { return block.subBlocks?.[subBlockId]?.value }