Skip to content

Commit 6022b12

Browse files
waleedlatif1claude
andcommitted
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>
1 parent 29189e4 commit 6022b12

File tree

7 files changed

+36
-52
lines changed

7 files changed

+36
-52
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
245245
const { id: workflowId } = await params
246246

247247
const incomingCallChain = parseCallChain(req.headers.get(SIM_VIA_HEADER))
248-
const callChainError = validateCallChain(incomingCallChain, workflowId)
248+
const callChainError = validateCallChain(incomingCallChain)
249249
if (callChainError) {
250250
logger.warn(`[${requestId}] Call chain rejected for workflow ${workflowId}: ${callChainError}`)
251251
return NextResponse.json({ error: callChainError }, { status: 409 })

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

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

171+
const childCallChain = buildNextCallChain(ctx.callChain || [], workflowId)
172+
const depthError = validateCallChain(ctx.callChain || [])
173+
if (depthError) {
174+
throw new ChildWorkflowError({
175+
message: depthError,
176+
childWorkflowName,
177+
})
178+
}
179+
171180
const subExecutor = new Executor({
172181
workflow: childWorkflow.serializedState,
173182
workflowInput: childWorkflowInput,
@@ -181,7 +190,7 @@ export class WorkflowBlockHandler implements BlockHandler {
181190
userId: ctx.userId,
182191
executionId: ctx.executionId,
183192
abortSignal: ctx.abortSignal,
184-
callChain: buildNextCallChain(ctx.callChain || [], workflowId),
193+
callChain: childCallChain,
185194
...(shouldPropagateCallbacks && {
186195
onBlockStart: ctx.onBlockStart,
187196
onBlockComplete: ctx.onBlockComplete,

apps/sim/lib/execution/__tests__/call-chain.test.ts

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -74,48 +74,28 @@ describe('call-chain', () => {
7474

7575
describe('validateCallChain', () => {
7676
it('returns null for an empty chain', () => {
77-
expect(validateCallChain([], 'wf-a')).toBeNull()
77+
expect(validateCallChain([])).toBeNull()
7878
})
7979

80-
it('returns null when workflow is not in chain', () => {
81-
expect(validateCallChain(['wf-a', 'wf-b'], 'wf-c')).toBeNull()
80+
it('returns null when chain is under max depth', () => {
81+
expect(validateCallChain(['wf-a', 'wf-b'])).toBeNull()
8282
})
8383

84-
it('detects direct self-call (A → A)', () => {
85-
const error = validateCallChain(['wf-a'], 'wf-a')
86-
expect(error).toContain('Workflow cycle detected')
87-
expect(error).toContain('wf-a → wf-a')
88-
})
89-
90-
it('detects indirect cycle (A → B → A)', () => {
91-
const error = validateCallChain(['wf-a', 'wf-b'], 'wf-a')
92-
expect(error).toContain('Workflow cycle detected')
93-
expect(error).toContain('wf-a → wf-b → wf-a')
94-
})
95-
96-
it('detects cycle mid-chain (A → B → C → B)', () => {
97-
const error = validateCallChain(['wf-a', 'wf-b', 'wf-c'], 'wf-b')
98-
expect(error).toContain('Workflow cycle detected')
99-
expect(error).toContain('wf-a → wf-b → wf-c → wf-b')
84+
it('allows legitimate self-recursion', () => {
85+
expect(validateCallChain(['wf-a', 'wf-a', 'wf-a'])).toBeNull()
10086
})
10187

10288
it('returns depth error when chain is at max depth', () => {
10389
const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH }, (_, i) => `wf-${i}`)
104-
const error = validateCallChain(chain, 'wf-new')
90+
const error = validateCallChain(chain)
10591
expect(error).toContain(
10692
`Maximum workflow call chain depth (${MAX_CALL_CHAIN_DEPTH}) exceeded`
10793
)
10894
})
10995

11096
it('allows chain just under max depth', () => {
11197
const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH - 1 }, (_, i) => `wf-${i}`)
112-
expect(validateCallChain(chain, 'wf-new')).toBeNull()
113-
})
114-
115-
it('prioritizes cycle detection over depth check', () => {
116-
const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH }, (_, i) => `wf-${i}`)
117-
const error = validateCallChain(chain, 'wf-0')
118-
expect(error).toContain('Workflow cycle detected')
98+
expect(validateCallChain(chain)).toBeNull()
11999
})
120100
})
121101

apps/sim/lib/execution/call-chain.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/**
22
* Workflow call chain detection using the Via-style pattern.
33
*
4-
* Prevents infinite execution loops when workflows call themselves (directly or
5-
* indirectly) via API or MCP endpoints. Each hop appends the current workflow ID
6-
* to the `X-Sim-Via` header; on ingress the chain is checked for cycles and depth.
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.
77
*/
88

99
export const SIM_VIA_HEADER = 'X-Sim-Via'
@@ -31,16 +31,11 @@ export function serializeCallChain(chain: string[]): string {
3131
}
3232

3333
/**
34-
* Validates that appending `workflowId` to `chain` would not create a cycle
35-
* or exceed the maximum depth. Returns an error message string if invalid,
36-
* or `null` if the chain is safe to extend.
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.
3737
*/
38-
export function validateCallChain(chain: string[], workflowId: string): string | null {
39-
if (chain.includes(workflowId)) {
40-
const cycleVisualization = [...chain, workflowId].join(' → ')
41-
return `Workflow cycle detected: ${cycleVisualization}. A workflow cannot call itself directly or indirectly.`
42-
}
43-
38+
export function validateCallChain(chain: string[]): string | null {
4439
if (chain.length >= MAX_CALL_CHAIN_DEPTH) {
4540
return `Maximum workflow call chain depth (${MAX_CALL_CHAIN_DEPTH}) exceeded.`
4641
}

apps/sim/tools/http/request.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,7 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
6969
headers: (params: RequestParams) => {
7070
const headers = transformTable(params.headers || null)
7171
const processedUrl = processUrl(params.url, params.pathParams, params.params)
72-
const callChain = (params as any)._context?.callChain as string[] | undefined
73-
const allHeaders = getDefaultHeaders(headers, processedUrl, callChain)
72+
const allHeaders = getDefaultHeaders(headers, processedUrl)
7473

7574
// Set appropriate Content-Type only if not already specified by user
7675
if (params.formData) {

apps/sim/tools/http/utils.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,16 @@
11
import { getBaseUrl } from '@/lib/core/utils/urls'
2-
import { SIM_VIA_HEADER, serializeCallChain } from '@/lib/execution/call-chain'
32
import { transformTable } from '@/tools/shared/table'
43
import type { TableRow } from '@/tools/types'
54

65
/**
76
* Creates a set of default headers used in HTTP requests
87
* @param customHeaders Additional user-provided headers to include
98
* @param url Target URL for the request (used for setting Host header)
10-
* @param callChain Optional workflow call chain for cycle detection
119
* @returns Record of HTTP headers
1210
*/
1311
export const getDefaultHeaders = (
1412
customHeaders: Record<string, string> = {},
15-
url?: string,
16-
callChain?: string[]
13+
url?: string
1714
): Record<string, string> => {
1815
const headers: Record<string, string> = {
1916
'User-Agent':
@@ -40,10 +37,6 @@ export const getDefaultHeaders = (
4037
}
4138
}
4239

43-
if (callChain && callChain.length > 0) {
44-
headers[SIM_VIA_HEADER] = serializeCallChain(callChain)
45-
}
46-
4740
return headers
4841
}
4942

apps/sim/tools/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from '@/lib/core/security/input-validation.server'
88
import { generateRequestId } from '@/lib/core/utils/request'
99
import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls'
10+
import { SIM_VIA_HEADER, serializeCallChain } from '@/lib/execution/call-chain'
1011
import { parseMcpToolId } from '@/lib/mcp/utils'
1112
import { isCustomTool, isMcpTool } from '@/executor/constants'
1213
import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver'
@@ -674,6 +675,13 @@ async function executeToolRequest(
674675
const headers = new Headers(requestParams.headers)
675676
await addInternalAuthIfNeeded(headers, isInternalRoute, requestId, toolId)
676677

678+
if (isInternalRoute) {
679+
const callChain = params._context?.callChain as string[] | undefined
680+
if (callChain && callChain.length > 0) {
681+
headers.set(SIM_VIA_HEADER, serializeCallChain(callChain))
682+
}
683+
}
684+
677685
// Check request body size before sending to detect potential size limit issues
678686
validateRequestBodySize(requestParams.body, requestId, toolId)
679687

0 commit comments

Comments
 (0)