Skip to content

Commit fc4f4c7

Browse files
committed
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.
1 parent 9064039 commit fc4f4c7

2 files changed

Lines changed: 93 additions & 11 deletions

File tree

apps/sim/executor/orchestrators/loop.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,62 @@ describe('LoopOrchestrator', () => {
308308
)
309309
})
310310

311+
describe('while condition reference interpolation', () => {
312+
async function evaluateWhileConditionFor(condition: string, resolved: unknown) {
313+
const resolver = { resolveSingleReference: vi.fn().mockResolvedValue(resolved) }
314+
const orchestrator = new LoopOrchestrator(
315+
{ loopConfigs: new Map(), parallelConfigs: new Map(), nodes: new Map() } as any,
316+
createState(),
317+
resolver as any
318+
)
319+
const ctx = createContext({
320+
iteration: 0,
321+
currentIterationOutputs: new Map(),
322+
allIterationOutputs: [],
323+
loopType: 'while',
324+
condition,
325+
})
326+
327+
await orchestrator.evaluateInitialCondition(ctx, 'loop-1')
328+
329+
return mockExecuteInIsolatedVM.mock.calls[0][0].code as string
330+
}
331+
332+
it('does not let a quote in a resolved value escape into expression position', async () => {
333+
const payload = 'z" ) && Boolean( "INJ".length === 3 ) && Boolean( "never'
334+
335+
const code = await evaluateWhileConditionFor('<start.input> === "never"', payload)
336+
337+
expect(new Function(code)()).toBe(false)
338+
expect(code).toBe(`return Boolean(${JSON.stringify(payload)} === "never")`)
339+
})
340+
341+
it('escapes backslashes and newlines so the condition stays compilable', async () => {
342+
const code = await evaluateWhileConditionFor('<start.input> === "x"', 'a\\"\nb')
343+
344+
expect(() => new Function(code)).not.toThrow()
345+
expect(new Function(code)()).toBe(false)
346+
})
347+
348+
it('escapes line separators that are legal in JSON but not in every JS host', async () => {
349+
const code = await evaluateWhileConditionFor('<start.input> === "x"', 'a\u2028b\u2029c')
350+
351+
expect(code).not.toMatch(/[\u2028\u2029]/)
352+
expect(new Function(code)()).toBe(false)
353+
})
354+
355+
it.each([
356+
['TRUE', 'return Boolean(true)'],
357+
[' false ', 'return Boolean(false)'],
358+
[7, 'return Boolean(7)'],
359+
[true, 'return Boolean(true)'],
360+
[null, 'return Boolean(null)'],
361+
[{ a: 1 }, 'return Boolean({"a":1})'],
362+
])('serializes %o as a literal operand', async (resolved, expected) => {
363+
expect(await evaluateWhileConditionFor('<start.input>', resolved)).toBe(expected)
364+
})
365+
})
366+
311367
it('exits doWhile loops when the configured iteration cap is reached', async () => {
312368
const { orchestrator } = createOrchestrator()
313369
const ctx = createContext({

apps/sim/executor/orchestrators/loop.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,42 @@ const logger = createLogger('LoopOrchestrator')
3838

3939
const LOOP_CONDITION_TIMEOUT_MS = 5000
4040

41+
/**
42+
* Serializes a resolved reference value into a JavaScript literal for the loop
43+
* condition expression.
44+
*
45+
* The result is concatenated into source that is compiled and run in the
46+
* execution isolate, so every value must be emitted as a self-contained literal.
47+
* Interpolating a string without escaping would let a `"` in the resolved data
48+
* terminate the literal and continue in expression position.
49+
*/
50+
function formatConditionOperand(resolved: unknown): string {
51+
if (typeof resolved === 'boolean' || typeof resolved === 'number') {
52+
return String(resolved)
53+
}
54+
55+
if (typeof resolved === 'string') {
56+
const lower = resolved.toLowerCase().trim()
57+
if (lower === 'true' || lower === 'false') {
58+
return lower
59+
}
60+
}
61+
62+
return toJsLiteral(resolved)
63+
}
64+
65+
/**
66+
* JSON-serializes a value and escapes the code points that are valid inside a
67+
* JSON string but historically hazardous inside a JavaScript source literal.
68+
*/
69+
function toJsLiteral(value: unknown): string {
70+
const serialized = JSON.stringify(value)
71+
if (serialized === undefined) {
72+
return 'undefined'
73+
}
74+
return serialized.replace(/\u2028/g, String.raw`\u2028`).replace(/\u2029/g, String.raw`\u2029`)
75+
}
76+
4177
async function replaceLoopConditionReferences(
4278
condition: string,
4379
replacer: (match: string) => Promise<string>
@@ -724,17 +760,7 @@ export class LoopOrchestrator {
724760
resolvedType: resolved === null ? 'null' : typeof resolved,
725761
})
726762
if (resolved !== undefined) {
727-
if (typeof resolved === 'boolean' || typeof resolved === 'number') {
728-
return String(resolved)
729-
}
730-
if (typeof resolved === 'string') {
731-
const lower = resolved.toLowerCase().trim()
732-
if (lower === 'true' || lower === 'false') {
733-
return lower
734-
}
735-
return `"${resolved}"`
736-
}
737-
return JSON.stringify(resolved)
763+
return formatConditionOperand(resolved)
738764
}
739765
return match
740766
})

0 commit comments

Comments
 (0)