Skip to content

Commit f54f7e0

Browse files
committed
Harden workflow sanitization and Slack setup
1 parent 85ffaa7 commit f54f7e0

7 files changed

Lines changed: 163 additions & 12 deletions

File tree

apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@ import { createLogger } from '@sim/logger'
1717
import { getErrorMessage } from '@sim/utils/errors'
1818
import { generateId } from '@sim/utils/id'
1919
import { SlackIcon } from '@/components/icons'
20-
import { getBaseUrl } from '@/lib/core/utils/urls'
2120
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
2221
import {
2322
useCreateWorkspaceCredential,
2423
useUpdateWorkspaceCredential,
2524
} from '@/hooks/queries/credentials'
2625
import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities'
26+
import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url'
2727

2828
const logger = createLogger('ConnectSlackBotModal')
2929

@@ -109,13 +109,9 @@ export function ConnectSlackBotModal({
109109
}
110110
}, [open, created, isReconnect, initialDisplayName, initialDescription])
111111

112-
// NEXT_PUBLIC_APP_URL, not window.location.origin: Slack's servers must be
113-
// able to reach this URL, so it has to be the app's public base (e.g. the
114-
// tunnel host in dev), not whatever host the browser happens to be on.
115-
const requestUrl = useMemo(
116-
() => `${getBaseUrl()}/api/webhooks/slack/custom/${credentialId}`,
117-
[credentialId]
118-
)
112+
// Shared server-side derivation: uses the app public base (not
113+
// window.location.origin) so Slack's servers can reach it.
114+
const requestUrl = useMemo(() => buildSlackCustomBotRequestUrl(credentialId), [credentialId])
119115

120116
const manifestJson = useMemo(() => {
121117
const manifest = buildSlackManifest(selected, {

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ import { BlockType, EDGE, normalizeName } from '@/executor/constants'
2222
import { isAutoModel, isKnownModelId, suggestModelIdsForUnknownModel } from '@/providers/models'
2323
import { isPiByokOnlyMode } from '@/providers/pi-providers'
2424
import { getTool } from '@/tools/utils'
25-
import { TRIGGER_RUNTIME_SUBBLOCK_IDS, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants'
25+
import {
26+
TRIGGER_ROUTING_FIELD,
27+
TRIGGER_RUNTIME_SUBBLOCK_IDS,
28+
TRIGGER_WEBHOOK_URL_FIELD,
29+
} from '@/triggers/constants'
2630
import type {
2731
EdgeHandleValidationResult,
2832
EditWorkflowOperation,
@@ -75,6 +79,17 @@ export function validateInputsForBlock(
7579
inputs = omit(inputs, [TRIGGER_WEBHOOK_URL_FIELD])
7680
}
7781

82+
if (TRIGGER_ROUTING_FIELD in inputs) {
83+
errors.push({
84+
blockId,
85+
blockType,
86+
field: TRIGGER_ROUTING_FIELD,
87+
value: inputs[TRIGGER_ROUTING_FIELD],
88+
error: `"${TRIGGER_ROUTING_FIELD}" is read-only. Event routing is derived from the selected credential and cannot be edited on the block.`,
89+
})
90+
inputs = omit(inputs, [TRIGGER_ROUTING_FIELD])
91+
}
92+
7893
const blockConfig = getBlock(blockType)
7994

8095
if (!blockConfig) {

apps/sim/lib/copilot/vfs/serializers.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
SANDBOX_SELECTABLE_CLI_TOOL_IDS,
1313
} from '@/lib/execution/remote-sandbox/cli-tools'
1414
import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types'
15+
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
1516
import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils'
1617
import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility'
1718
import { getBlock } from '@/blocks'
@@ -24,6 +25,8 @@ import {
2425
SIM_AUTO_MODEL_ID,
2526
} from '@/providers/models'
2627
import type { ToolConfig, ToolHostingCondition } from '@/tools/types'
28+
import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities'
29+
import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url'
2730

2831
/** The service-account alternative to OAuth for a service, when it offers one. */
2932
export interface VfsServiceAccountAuth {
@@ -733,6 +736,15 @@ export function serializeCredentials(
733736
// credential) — they reconnect differently, so the agent must branch on
734737
// this. Env-var credentials carry no type.
735738
type: a.credentialType,
739+
// Derived, not stored: the public Request URL a Slack custom-bot app
740+
// posts events to. One per credential; every workflow trigger that
741+
// selects this credential shares it. This is what the setup wizard shows
742+
// in Slack's Event Subscriptions step.
743+
...(a.credentialType === 'service_account' &&
744+
a.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID &&
745+
a.id
746+
? { requestUrl: buildSlackCustomBotRequestUrl(a.id) }
747+
: {}),
736748
connectedAt: a.createdAt.toISOString(),
737749
})),
738750
null,
@@ -1137,6 +1149,38 @@ export function serializeIntegrationSchema(
11371149
)
11381150
}
11391151

1152+
/**
1153+
* Derived setup reference for `slack_oauth` — the same material the custom-bot
1154+
* setup wizard shows, surfaced so the copilot can walk a user (or the browser
1155+
* agent) through Slack app creation without guessing. None of this is a block
1156+
* field: the manifest is a template for api.slack.com, and the Request URL is a
1157+
* per-credential property (`requestUrl` in environment/credentials.json).
1158+
*/
1159+
function slackOAuthSetupReference(): Record<string, unknown> {
1160+
const defaults = SLACK_CAPABILITIES.filter((c) => c.defaultChecked).map((c) => c.id)
1161+
return {
1162+
note:
1163+
'Setup reference (derived; NOT block fields). A custom bot is a reusable workspace credential: ' +
1164+
'one Slack app, one Request URL, shared by every trigger that selects it. To create or rotate one, ' +
1165+
'emit a service_account credential card for provider "slack" — the wizard collects the signing secret ' +
1166+
'and bot token without them entering the chat. Existing custom bots appear as service_account ' +
1167+
'credentials in environment/credentials.json, each with its requestUrl.',
1168+
requestUrlPattern: '{baseUrl}/api/webhooks/slack/custom/{credentialId}',
1169+
capabilities: SLACK_CAPABILITIES.map((c) => ({
1170+
id: c.id,
1171+
label: c.label,
1172+
group: c.group,
1173+
defaultChecked: c.defaultChecked,
1174+
scopes: c.scopes,
1175+
events: c.events,
1176+
})),
1177+
defaultManifest: buildSlackManifest(new Set(defaults), {
1178+
appName: 'Sim Bot',
1179+
webhookUrl: '<the credential requestUrl>',
1180+
}),
1181+
}
1182+
}
1183+
11401184
/**
11411185
* Serialize a trigger schema for VFS components/triggers/{provider}/{id}.json
11421186
*/
@@ -1160,6 +1204,7 @@ export function serializeTriggerSchema(trigger: {
11601204
webhook: trigger.webhook || undefined,
11611205
subBlocks: trigger.subBlocks.map(serializeSubBlock),
11621206
outputs: trigger.outputs,
1207+
...(trigger.id === 'slack_oauth' ? { setup: slackOAuthSetupReference() } : {}),
11631208
},
11641209
null,
11651210
2

apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { resetUrlsMock, urlsMockFns } from '@sim/testing'
55
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
66
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
77
import type { WorkflowState } from '@/stores/workflows/workflow/types'
8-
import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants'
8+
import { TRIGGER_ROUTING_FIELD, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants'
99

1010
beforeAll(() => {
1111
urlsMockFns.mockGetBaseUrl.mockReturnValue('https://sim.test')
@@ -282,3 +282,45 @@ describe('sanitizeForCopilot webhook trigger URL', () => {
282282
expect(result.blocks['gh-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_WEBHOOK_URL_FIELD)
283283
})
284284
})
285+
286+
describe('sanitizeForCopilot credential-routed trigger routing', () => {
287+
it('synthesizes the read-only routing note for a slack_v2 block in trigger mode', () => {
288+
const result = sanitizeForCopilot(
289+
makeSingleBlockWorkflow('slack-1', {
290+
type: 'slack_v2',
291+
name: 'Slack Trigger',
292+
enabled: true,
293+
triggerMode: true,
294+
subBlocks: {
295+
selectedTriggerId: { id: 'selectedTriggerId', type: 'short-input', value: 'slack_oauth' },
296+
customBotCredential: {
297+
id: 'customBotCredential',
298+
type: 'oauth-input',
299+
value: 'cred-123',
300+
},
301+
},
302+
})
303+
)
304+
305+
const routing = result.blocks['slack-1'].inputs?.[TRIGGER_ROUTING_FIELD] as
306+
| Record<string, unknown>
307+
| undefined
308+
expect(routing?.model).toBe('credential-routed')
309+
expect(routing?.selectedCredentialId).toBe('cred-123')
310+
expect(String(routing?.note)).toContain('no per-workflow webhook URL')
311+
expect(result.blocks['slack-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_WEBHOOK_URL_FIELD)
312+
})
313+
314+
it('omits the routing note when the block is not in trigger mode', () => {
315+
const result = sanitizeForCopilot(
316+
makeSingleBlockWorkflow('slack-1', {
317+
type: 'slack_v2',
318+
name: 'Slack Action',
319+
enabled: true,
320+
subBlocks: {},
321+
})
322+
)
323+
324+
expect(result.blocks['slack-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_ROUTING_FIELD)
325+
})
326+
})

apps/sim/lib/workflows/sanitization/json-sanitizer.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import type {
1212
WorkflowState,
1313
} from '@/stores/workflows/workflow/types'
1414
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
15-
import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants'
16-
import { blockAdvertisesWebhookUrl } from '@/triggers/webhook-url'
15+
import { TRIGGER_ROUTING_FIELD, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants'
16+
import { blockAdvertisesWebhookUrl, resolveBlockTriggerId } from '@/triggers/webhook-url'
1717

1818
/**
1919
* Sanitized workflow state for copilot (removes all UI-specific data)
@@ -364,6 +364,35 @@ function resolveTriggerWebhookUrl(blockId: string, block: BlockState): string |
364364
}
365365
}
366366

367+
/** Trigger ids that deliver by credential routing — no per-workflow URL exists. */
368+
const CREDENTIAL_ROUTED_TRIGGER_IDS = new Set(['slack_oauth'])
369+
370+
/**
371+
* Derived routing note for trigger blocks that have NO per-workflow webhook URL
372+
* (credential-routed delivery, e.g. Slack v2's `slack_oauth`). Mirrors what the
373+
* setup wizard shows: events arrive at the selected credential's endpoint — a
374+
* custom bot's per-credential Request URL (surfaced as `requestUrl` on that
375+
* credential in environment/credentials.json) or the shared Sim-app endpoint
376+
* routed by Slack workspace. Surfaced as the read-only
377+
* {@link TRIGGER_ROUTING_FIELD} input; rejected on write by `edit_workflow`.
378+
*/
379+
function resolveTriggerRouting(block: BlockState): Record<string, unknown> | null {
380+
const triggerId = resolveBlockTriggerId(block)
381+
if (!triggerId || !CREDENTIAL_ROUTED_TRIGGER_IDS.has(triggerId)) return null
382+
const selected =
383+
block.subBlocks?.customBotCredential?.value ?? block.subBlocks?.manualBotCredential?.value
384+
const selectedCredentialId = typeof selected === 'string' && selected.length > 0 ? selected : null
385+
return {
386+
model: 'credential-routed',
387+
note:
388+
'This trigger has no per-workflow webhook URL. Events are delivered via the selected Slack credential: ' +
389+
'a custom bot posts to its per-credential Request URL (the requestUrl field on that credential in ' +
390+
'environment/credentials.json — the same URL the setup wizard shows for Slack Event Subscriptions); ' +
391+
'a Sim-app connection routes by Slack workspace automatically. Derived at read time; not an editable field.',
392+
...(selectedCredentialId ? { selectedCredentialId } : {}),
393+
}
394+
}
395+
367396
/**
368397
* Convert internal condition handle (condition-{uuid}) to simple format (if, else-if-0, else)
369398
* Uses 0-indexed numbering for else-if conditions
@@ -587,6 +616,10 @@ export function sanitizeForCopilot(
587616
if (webhookUrl) {
588617
inputs[TRIGGER_WEBHOOK_URL_FIELD] = webhookUrl
589618
}
619+
const triggerRouting = resolveTriggerRouting(block)
620+
if (triggerRouting) {
621+
inputs[TRIGGER_ROUTING_FIELD] = triggerRouting
622+
}
590623
}
591624

592625
// Check if this is a loop or parallel (has children)

apps/sim/triggers/constants.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [
4040
*/
4141
export const TRIGGER_WEBHOOK_URL_FIELD = 'triggerWebhookUrl'
4242

43+
/**
44+
* Derived, read-only input surfaced on copilot reads of trigger blocks that
45+
* route by CREDENTIAL rather than a per-workflow webhook URL (e.g. Slack v2's
46+
* `slack_oauth`). Explains where events actually arrive — a custom bot's
47+
* per-credential Request URL or the shared Sim-app endpoint — so the copilot
48+
* can answer "where do I point Slack?" without inventing a field. Never
49+
* stored; rejected on write like {@link TRIGGER_WEBHOOK_URL_FIELD}.
50+
*/
51+
export const TRIGGER_ROUTING_FIELD = 'triggerRouting'
52+
4353
/**
4454
* Maximum number of consecutive failures before a trigger (schedule/webhook) is auto-disabled.
4555
* This prevents runaway errors from continuously executing failing workflows.

apps/sim/triggers/webhook-url.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ export function buildWebhookTriggerUrl(path: string): string {
1212
return `${getBaseUrl()}/api/webhooks/trigger/${path}`
1313
}
1414

15+
/**
16+
* The Request URL a Slack custom-bot app posts events to. One URL per
17+
* credential (not per workflow): the endpoint verifies with the credential's
18+
* signing secret and fans out to every workflow whose trigger routes by this
19+
* credential id. Uses the app's public base so Slack's servers can reach it.
20+
*/
21+
export function buildSlackCustomBotRequestUrl(credentialId: string): string {
22+
return `${getBaseUrl()}/api/webhooks/slack/custom/${credentialId}`
23+
}
24+
1525
function subBlockValue(block: BlockState, subBlockId: string): unknown {
1626
return block.subBlocks?.[subBlockId]?.value
1727
}

0 commit comments

Comments
 (0)