From fc4f4c74e9cdc241f8b1f6d972f2ef3f1a36a762 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 18:55:18 -0700 Subject: [PATCH] fix(executor): escape resolved references in while/doWhile loop conditions Loop conditions inlined each resolved reference as a bare double-quoted literal before compiling the expression in the execution isolate, so a quote anywhere in the referenced value broke out of the literal and ran as code. Serialize operands as proper JS literals instead, matching the escaping the condition block already does via stringifyForCondition. --- apps/sim/executor/orchestrators/loop.test.ts | 56 ++++++++++++++++++++ apps/sim/executor/orchestrators/loop.ts | 48 +++++++++++++---- 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/apps/sim/executor/orchestrators/loop.test.ts b/apps/sim/executor/orchestrators/loop.test.ts index 9180fc2bd8d..54f60f13fc7 100644 --- a/apps/sim/executor/orchestrators/loop.test.ts +++ b/apps/sim/executor/orchestrators/loop.test.ts @@ -308,6 +308,62 @@ describe('LoopOrchestrator', () => { ) }) + describe('while condition reference interpolation', () => { + async function evaluateWhileConditionFor(condition: string, resolved: unknown) { + const resolver = { resolveSingleReference: vi.fn().mockResolvedValue(resolved) } + const orchestrator = new LoopOrchestrator( + { loopConfigs: new Map(), parallelConfigs: new Map(), nodes: new Map() } as any, + createState(), + resolver as any + ) + const ctx = createContext({ + iteration: 0, + currentIterationOutputs: new Map(), + allIterationOutputs: [], + loopType: 'while', + condition, + }) + + await orchestrator.evaluateInitialCondition(ctx, 'loop-1') + + return mockExecuteInIsolatedVM.mock.calls[0][0].code as string + } + + it('does not let a quote in a resolved value escape into expression position', async () => { + const payload = 'z" ) && Boolean( "INJ".length === 3 ) && Boolean( "never' + + const code = await evaluateWhileConditionFor(' === "never"', payload) + + expect(new Function(code)()).toBe(false) + expect(code).toBe(`return Boolean(${JSON.stringify(payload)} === "never")`) + }) + + it('escapes backslashes and newlines so the condition stays compilable', async () => { + const code = await evaluateWhileConditionFor(' === "x"', 'a\\"\nb') + + expect(() => new Function(code)).not.toThrow() + expect(new Function(code)()).toBe(false) + }) + + it('escapes line separators that are legal in JSON but not in every JS host', async () => { + const code = await evaluateWhileConditionFor(' === "x"', 'a\u2028b\u2029c') + + expect(code).not.toMatch(/[\u2028\u2029]/) + expect(new Function(code)()).toBe(false) + }) + + it.each([ + ['TRUE', 'return Boolean(true)'], + [' false ', 'return Boolean(false)'], + [7, 'return Boolean(7)'], + [true, 'return Boolean(true)'], + [null, 'return Boolean(null)'], + [{ a: 1 }, 'return Boolean({"a":1})'], + ])('serializes %o as a literal operand', async (resolved, expected) => { + expect(await evaluateWhileConditionFor('', resolved)).toBe(expected) + }) + }) + it('exits doWhile loops when the configured iteration cap is reached', async () => { const { orchestrator } = createOrchestrator() const ctx = createContext({ diff --git a/apps/sim/executor/orchestrators/loop.ts b/apps/sim/executor/orchestrators/loop.ts index 913de932915..35f67b9c393 100644 --- a/apps/sim/executor/orchestrators/loop.ts +++ b/apps/sim/executor/orchestrators/loop.ts @@ -38,6 +38,42 @@ const logger = createLogger('LoopOrchestrator') const LOOP_CONDITION_TIMEOUT_MS = 5000 +/** + * Serializes a resolved reference value into a JavaScript literal for the loop + * condition expression. + * + * The result is concatenated into source that is compiled and run in the + * execution isolate, so every value must be emitted as a self-contained literal. + * Interpolating a string without escaping would let a `"` in the resolved data + * terminate the literal and continue in expression position. + */ +function formatConditionOperand(resolved: unknown): string { + if (typeof resolved === 'boolean' || typeof resolved === 'number') { + return String(resolved) + } + + if (typeof resolved === 'string') { + const lower = resolved.toLowerCase().trim() + if (lower === 'true' || lower === 'false') { + return lower + } + } + + return toJsLiteral(resolved) +} + +/** + * JSON-serializes a value and escapes the code points that are valid inside a + * JSON string but historically hazardous inside a JavaScript source literal. + */ +function toJsLiteral(value: unknown): string { + const serialized = JSON.stringify(value) + if (serialized === undefined) { + return 'undefined' + } + return serialized.replace(/\u2028/g, String.raw`\u2028`).replace(/\u2029/g, String.raw`\u2029`) +} + async function replaceLoopConditionReferences( condition: string, replacer: (match: string) => Promise @@ -724,17 +760,7 @@ export class LoopOrchestrator { resolvedType: resolved === null ? 'null' : typeof resolved, }) if (resolved !== undefined) { - if (typeof resolved === 'boolean' || typeof resolved === 'number') { - return String(resolved) - } - if (typeof resolved === 'string') { - const lower = resolved.toLowerCase().trim() - if (lower === 'true' || lower === 'false') { - return lower - } - return `"${resolved}"` - } - return JSON.stringify(resolved) + return formatConditionOperand(resolved) } return match })