Skip to content

Commit 9166649

Browse files
waleedlatif1claude
andauthored
fix(execution): scope X-Sim-Via header to internal routes and enforce depth limit (#3313)
* feat(execution): workflow cycle detection via X-Sim-Via header * fix(execution): scope X-Sim-Via header to internal routes and add child workflow depth validation - Move call chain header injection from HTTP tool layer (request.ts/utils.ts) to tool execution layer (tools/index.ts) gated on isInternalRoute, preventing internal workflow IDs from leaking to external third-party APIs - Remove cycle detection from validateCallChain — depth limit alone prevents infinite loops while allowing legitimate self-recursion (pagination, tree processing, batch splitting) - Add validateCallChain check in workflow-handler.ts before spawning child executor, closing the gap where in-process child workflows skipped validation - Remove unsafe `(params as any)._context` type bypass in request.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(execution): validate child call chain instead of parent chain Validate childCallChain (after appending current workflow ID) rather than ctx.callChain (parent). Prevents an off-by-one where a chain at depth 10 could still spawn an 11th workflow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent eafbb9f commit 9166649

File tree

12 files changed

+247
-3
lines changed

12 files changed

+247
-3
lines changed

apps/sim/app/api/mcp/serve/[serverId]/route.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
2323
import { generateInternalToken } from '@/lib/auth/internal'
2424
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
2525
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
26+
import { SIM_VIA_HEADER } from '@/lib/execution/call-chain'
2627
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
2728

2829
const logger = createLogger('WorkflowMcpServeAPI')
@@ -181,7 +182,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
181182
serverId,
182183
rpcParams as { name: string; arguments?: Record<string, unknown> },
183184
executeAuthContext,
184-
server.isPublic ? server.createdBy : undefined
185+
server.isPublic ? server.createdBy : undefined,
186+
request.headers.get(SIM_VIA_HEADER)
185187
)
186188

187189
default:
@@ -244,7 +246,8 @@ async function handleToolsCall(
244246
serverId: string,
245247
params: { name: string; arguments?: Record<string, unknown> } | undefined,
246248
executeAuthContext?: ExecuteAuthContext | null,
247-
publicServerOwnerId?: string
249+
publicServerOwnerId?: string,
250+
simViaHeader?: string | null
248251
): Promise<NextResponse> {
249252
try {
250253
if (!params?.name) {
@@ -300,6 +303,10 @@ async function handleToolsCall(
300303
}
301304
}
302305

306+
if (simViaHeader) {
307+
headers[SIM_VIA_HEADER] = simViaHeader
308+
}
309+
303310
logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`)
304311

305312
const response = await fetch(executeUrl, {

apps/sim/app/api/workflows/[id]/execute/route.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ import {
1212
import { generateRequestId } from '@/lib/core/utils/request'
1313
import { SSE_HEADERS } from '@/lib/core/utils/sse'
1414
import { getBaseUrl } from '@/lib/core/utils/urls'
15+
import {
16+
buildNextCallChain,
17+
parseCallChain,
18+
SIM_VIA_HEADER,
19+
validateCallChain,
20+
} from '@/lib/execution/call-chain'
1521
import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer'
1622
import { processInputFileFields } from '@/lib/execution/files'
1723
import { preprocessExecution } from '@/lib/execution/preprocessing'
@@ -155,17 +161,19 @@ type AsyncExecutionParams = {
155161
input: any
156162
triggerType: CoreTriggerType
157163
executionId: string
164+
callChain?: string[]
158165
}
159166

160167
async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextResponse> {
161-
const { requestId, workflowId, userId, input, triggerType, executionId } = params
168+
const { requestId, workflowId, userId, input, triggerType, executionId, callChain } = params
162169

163170
const payload: WorkflowExecutionPayload = {
164171
workflowId,
165172
userId,
166173
input,
167174
triggerType,
168175
executionId,
176+
callChain,
169177
}
170178

171179
try {
@@ -236,6 +244,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
236244
const requestId = generateRequestId()
237245
const { id: workflowId } = await params
238246

247+
const incomingCallChain = parseCallChain(req.headers.get(SIM_VIA_HEADER))
248+
const callChainError = validateCallChain(incomingCallChain)
249+
if (callChainError) {
250+
logger.warn(`[${requestId}] Call chain rejected for workflow ${workflowId}: ${callChainError}`)
251+
return NextResponse.json({ error: callChainError }, { status: 409 })
252+
}
253+
const callChain = buildNextCallChain(incomingCallChain, workflowId)
254+
239255
try {
240256
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
241257
if (!auth.success || !auth.userId) {
@@ -433,6 +449,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
433449
input,
434450
triggerType: loggingTriggerType,
435451
executionId,
452+
callChain,
436453
})
437454
}
438455

@@ -539,6 +556,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
539556
isClientSession,
540557
enforceCredentialAccess: useAuthenticatedUserAsActor,
541558
workflowStateOverride: effectiveWorkflowStateOverride,
559+
callChain,
542560
}
543561

544562
const executionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
@@ -909,6 +927,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
909927
isClientSession,
910928
enforceCredentialAccess: useAuthenticatedUserAsActor,
911929
workflowStateOverride: effectiveWorkflowStateOverride,
930+
callChain,
912931
}
913932

914933
const sseExecutionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}

apps/sim/background/workflow-execution.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type WorkflowExecutionPayload = {
2222
triggerType?: CoreTriggerType
2323
executionId?: string
2424
metadata?: Record<string, any>
25+
callChain?: string[]
2526
}
2627

2728
/**
@@ -95,6 +96,7 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
9596
useDraftState: false,
9697
startTime: new Date().toISOString(),
9798
isClientSession: false,
99+
callChain: payload.callChain,
98100
}
99101

100102
const snapshot = new ExecutionSnapshot(

apps/sim/executor/execution/executor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ export class DAGExecutor {
330330
base64MaxBytes: this.contextExtensions.base64MaxBytes,
331331
runFromBlockContext: overrides?.runFromBlockContext,
332332
stopAfterBlockId: this.contextExtensions.stopAfterBlockId,
333+
callChain: this.contextExtensions.callChain,
333334
}
334335

335336
if (this.contextExtensions.resumeFromSnapshot) {

apps/sim/executor/execution/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface ExecutionMetadata {
2727
parallels?: Record<string, any>
2828
deploymentVersionId?: string
2929
}
30+
callChain?: string[]
3031
}
3132

3233
export interface SerializableExecutionState {
@@ -167,6 +168,12 @@ export interface ContextExtensions {
167168
* Stop execution after this block completes. Used for "run until block" feature.
168169
*/
169170
stopAfterBlockId?: string
171+
172+
/**
173+
* Ordered list of workflow IDs in the current call chain, used for cycle detection.
174+
* Each hop appends the current workflow ID before making outgoing requests.
175+
*/
176+
callChain?: string[]
170177
}
171178

172179
export interface WorkflowInput {

apps/sim/executor/handlers/api/api-handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export class ApiBlockHandler implements BlockHandler {
7575
userId: ctx.userId,
7676
isDeployedContext: ctx.isDeployedContext,
7777
enforceCredentialAccess: ctx.enforceCredentialAccess,
78+
callChain: ctx.callChain,
7879
},
7980
},
8081
false,

apps/sim/executor/handlers/workflow/workflow-handler.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { buildNextCallChain, validateCallChain } from '@/lib/execution/call-chain'
23
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
34
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
45
import type { TraceSpan } from '@/lib/logs/types'
@@ -167,6 +168,15 @@ export class WorkflowBlockHandler implements BlockHandler {
167168
ctx.onChildWorkflowInstanceReady?.(effectiveBlockId, instanceId, iterationContext)
168169
}
169170

171+
const childCallChain = buildNextCallChain(ctx.callChain || [], workflowId)
172+
const depthError = validateCallChain(childCallChain)
173+
if (depthError) {
174+
throw new ChildWorkflowError({
175+
message: depthError,
176+
childWorkflowName,
177+
})
178+
}
179+
170180
const subExecutor = new Executor({
171181
workflow: childWorkflow.serializedState,
172182
workflowInput: childWorkflowInput,
@@ -180,6 +190,7 @@ export class WorkflowBlockHandler implements BlockHandler {
180190
userId: ctx.userId,
181191
executionId: ctx.executionId,
182192
abortSignal: ctx.abortSignal,
193+
callChain: childCallChain,
183194
...(shouldPropagateCallbacks && {
184195
onBlockStart: ctx.onBlockStart,
185196
onBlockComplete: ctx.onBlockComplete,

apps/sim/executor/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,12 @@ export interface ExecutionContext {
301301
*/
302302
stopAfterBlockId?: string
303303

304+
/**
305+
* Ordered list of workflow IDs in the current call chain, used for cycle detection.
306+
* Passed to outgoing HTTP requests via the X-Sim-Via header.
307+
*/
308+
callChain?: string[]
309+
304310
/**
305311
* Counter for generating monotonically increasing execution order values.
306312
* Starts at 0 and increments for each block. Use getNextExecutionOrder() to access.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
buildNextCallChain,
7+
MAX_CALL_CHAIN_DEPTH,
8+
parseCallChain,
9+
SIM_VIA_HEADER,
10+
serializeCallChain,
11+
validateCallChain,
12+
} from '@/lib/execution/call-chain'
13+
14+
describe('call-chain', () => {
15+
describe('SIM_VIA_HEADER', () => {
16+
it('has the expected header name', () => {
17+
expect(SIM_VIA_HEADER).toBe('X-Sim-Via')
18+
})
19+
})
20+
21+
describe('MAX_CALL_CHAIN_DEPTH', () => {
22+
it('equals 10', () => {
23+
expect(MAX_CALL_CHAIN_DEPTH).toBe(10)
24+
})
25+
})
26+
27+
describe('parseCallChain', () => {
28+
it('returns empty array for null', () => {
29+
expect(parseCallChain(null)).toEqual([])
30+
})
31+
32+
it('returns empty array for undefined', () => {
33+
expect(parseCallChain(undefined)).toEqual([])
34+
})
35+
36+
it('returns empty array for empty string', () => {
37+
expect(parseCallChain('')).toEqual([])
38+
})
39+
40+
it('returns empty array for whitespace-only string', () => {
41+
expect(parseCallChain(' ')).toEqual([])
42+
})
43+
44+
it('parses a single workflow ID', () => {
45+
expect(parseCallChain('wf-abc')).toEqual(['wf-abc'])
46+
})
47+
48+
it('parses multiple comma-separated workflow IDs', () => {
49+
expect(parseCallChain('wf-a,wf-b,wf-c')).toEqual(['wf-a', 'wf-b', 'wf-c'])
50+
})
51+
52+
it('trims whitespace around workflow IDs', () => {
53+
expect(parseCallChain(' wf-a , wf-b , wf-c ')).toEqual(['wf-a', 'wf-b', 'wf-c'])
54+
})
55+
56+
it('filters out empty segments', () => {
57+
expect(parseCallChain('wf-a,,wf-b')).toEqual(['wf-a', 'wf-b'])
58+
})
59+
})
60+
61+
describe('serializeCallChain', () => {
62+
it('serializes an empty array', () => {
63+
expect(serializeCallChain([])).toBe('')
64+
})
65+
66+
it('serializes a single ID', () => {
67+
expect(serializeCallChain(['wf-a'])).toBe('wf-a')
68+
})
69+
70+
it('serializes multiple IDs with commas', () => {
71+
expect(serializeCallChain(['wf-a', 'wf-b', 'wf-c'])).toBe('wf-a,wf-b,wf-c')
72+
})
73+
})
74+
75+
describe('validateCallChain', () => {
76+
it('returns null for an empty chain', () => {
77+
expect(validateCallChain([])).toBeNull()
78+
})
79+
80+
it('returns null when chain is under max depth', () => {
81+
expect(validateCallChain(['wf-a', 'wf-b'])).toBeNull()
82+
})
83+
84+
it('allows legitimate self-recursion', () => {
85+
expect(validateCallChain(['wf-a', 'wf-a', 'wf-a'])).toBeNull()
86+
})
87+
88+
it('returns depth error when chain is at max depth', () => {
89+
const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH }, (_, i) => `wf-${i}`)
90+
const error = validateCallChain(chain)
91+
expect(error).toContain(
92+
`Maximum workflow call chain depth (${MAX_CALL_CHAIN_DEPTH}) exceeded`
93+
)
94+
})
95+
96+
it('allows chain just under max depth', () => {
97+
const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH - 1 }, (_, i) => `wf-${i}`)
98+
expect(validateCallChain(chain)).toBeNull()
99+
})
100+
})
101+
102+
describe('buildNextCallChain', () => {
103+
it('appends workflow ID to empty chain', () => {
104+
expect(buildNextCallChain([], 'wf-a')).toEqual(['wf-a'])
105+
})
106+
107+
it('appends workflow ID to existing chain', () => {
108+
expect(buildNextCallChain(['wf-a', 'wf-b'], 'wf-c')).toEqual(['wf-a', 'wf-b', 'wf-c'])
109+
})
110+
111+
it('does not mutate the original chain', () => {
112+
const original = ['wf-a']
113+
const result = buildNextCallChain(original, 'wf-b')
114+
expect(original).toEqual(['wf-a'])
115+
expect(result).toEqual(['wf-a', 'wf-b'])
116+
})
117+
})
118+
119+
describe('round-trip', () => {
120+
it('parse → serialize is identity', () => {
121+
const header = 'wf-a,wf-b,wf-c'
122+
expect(serializeCallChain(parseCallChain(header))).toBe(header)
123+
})
124+
125+
it('serialize → parse is identity', () => {
126+
const chain = ['wf-a', 'wf-b', 'wf-c']
127+
expect(parseCallChain(serializeCallChain(chain))).toEqual(chain)
128+
})
129+
})
130+
})
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Workflow call chain detection using the Via-style pattern.
3+
*
4+
* Prevents infinite execution loops when workflows call each other via API or
5+
* MCP endpoints. Each hop appends the current workflow ID to the `X-Sim-Via`
6+
* header; on ingress the chain is checked for depth.
7+
*/
8+
9+
export const SIM_VIA_HEADER = 'X-Sim-Via'
10+
export const MAX_CALL_CHAIN_DEPTH = 10
11+
12+
/**
13+
* Parses the `X-Sim-Via` header value into an ordered list of workflow IDs.
14+
* Returns an empty array when the header is absent or empty.
15+
*/
16+
export function parseCallChain(headerValue: string | null | undefined): string[] {
17+
if (!headerValue || !headerValue.trim()) {
18+
return []
19+
}
20+
return headerValue
21+
.split(',')
22+
.map((id) => id.trim())
23+
.filter(Boolean)
24+
}
25+
26+
/**
27+
* Serializes a call chain array back into the header value format.
28+
*/
29+
export function serializeCallChain(chain: string[]): string {
30+
return chain.join(',')
31+
}
32+
33+
/**
34+
* Validates that the call chain has not exceeded the maximum depth.
35+
* Returns an error message string if invalid, or `null` if the chain is
36+
* safe to extend.
37+
*/
38+
export function validateCallChain(chain: string[]): string | null {
39+
if (chain.length >= MAX_CALL_CHAIN_DEPTH) {
40+
return `Maximum workflow call chain depth (${MAX_CALL_CHAIN_DEPTH}) exceeded.`
41+
}
42+
43+
return null
44+
}
45+
46+
/**
47+
* Builds the next call chain by appending the current workflow ID.
48+
*/
49+
export function buildNextCallChain(chain: string[], workflowId: string): string[] {
50+
return [...chain, workflowId]
51+
}

0 commit comments

Comments
 (0)