From 4c4b208ab1cae1ab3fa26d2d516cb7bcb213906c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 15 Aug 2026 18:37:36 -0700 Subject: [PATCH 1/3] fix(sandbox): undefine the raw fetch host bridge before user code runs --- .../isolated-vm-worker-hardening.test.ts | 52 +++++++++++++++++++ apps/sim/lib/execution/isolated-vm-worker.cjs | 15 ++++-- 2 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 apps/sim/lib/execution/isolated-vm-worker-hardening.test.ts diff --git a/apps/sim/lib/execution/isolated-vm-worker-hardening.test.ts b/apps/sim/lib/execution/isolated-vm-worker-hardening.test.ts new file mode 100644 index 00000000000..89833315f2f --- /dev/null +++ b/apps/sim/lib/execution/isolated-vm-worker-hardening.test.ts @@ -0,0 +1,52 @@ +/** + * @vitest-environment node + * + * Guards the isolate hardening contract in `isolated-vm-worker.cjs`: no raw + * `ivm.Reference` host bridge may survive as an isolate global once user code + * runs. The bootstrap must capture each bridge in a closure and list its global + * name in `undefined_globals`. + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const WORKER_SOURCE = readFileSync(join(__dirname, 'isolated-vm-worker.cjs'), 'utf8') + +/** Global names bound to a raw `ivm.Reference` (as opposed to an `ivm.Callback`). */ +const REFERENCE_BRIDGES = [ + '__fetchRef', + '__brokerRef', + '__setTimeoutRef', + '__clearTimeoutRef', + '__setIntervalRef', +] + +function hardeningLists(): string[] { + const lists = WORKER_SOURCE.match(/const undefined_globals = \[[\s\S]*?\]/g) + expect(lists).not.toBeNull() + return lists as string[] +} + +describe('isolated-vm worker hardening', () => { + it('undefines every ivm.Reference bridge it installs as a global', () => { + const lists = hardeningLists() + expect(lists.length).toBeGreaterThanOrEqual(2) + + for (const bridge of REFERENCE_BRIDGES) { + const installed = WORKER_SOURCE.includes(`jail.set('${bridge}'`) + if (!installed) continue + const undefinedSomewhere = lists.some((list) => list.includes(`'${bridge}'`)) + expect(undefinedSomewhere, `${bridge} is set as an isolate global but never undefined`).toBe( + true + ) + } + }) + + it('keeps the isolated-vm escape globals in every hardening list', () => { + for (const list of hardeningLists()) { + for (const name of ['Isolate', 'Context', 'Script', 'Reference', 'ExternalCopy']) { + expect(list).toContain(`'${name}'`) + } + } + }) +}) diff --git a/apps/sim/lib/execution/isolated-vm-worker.cjs b/apps/sim/lib/execution/isolated-vm-worker.cjs index 0ccc4387943..210c55b796d 100644 --- a/apps/sim/lib/execution/isolated-vm-worker.cjs +++ b/apps/sim/lib/execution/isolated-vm-worker.cjs @@ -310,8 +310,12 @@ async function executeCode(request, executionId) { info: (...args) => __log(...args), }; - // Set up fetch function that uses the host's secure fetch - async function fetch(url, options) { + // Set up fetch function that uses the host's secure fetch. The raw + // host bridge is captured in this closure so the hardening step below + // can undefine the global without breaking fetch(). + (() => { + const __fetch = globalThis.__fetchRef; + globalThis.fetch = async function fetch(url, options) { let optionsJson; if (options) { try { @@ -323,7 +327,7 @@ async function executeCode(request, executionId) { throw new Error('fetch options exceed maximum payload size'); } } - const resultJson = await __fetchRef.apply(undefined, [url, optionsJson], { result: { promise: true } }); + const resultJson = await __fetch.apply(undefined, [url, optionsJson], { result: { promise: true } }); let result; try { result = JSON.parse(resultJson); @@ -355,7 +359,8 @@ async function executeCode(request, executionId) { blob: async () => { throw new Error('blob() not supported in sandbox'); }, arrayBuffer: async () => { throw new Error('arrayBuffer() not supported in sandbox'); }, }; - } + }; + })(); const sim = (() => { const broker = __brokerRef; @@ -408,7 +413,7 @@ async function executeCode(request, executionId) { const undefined_globals = [ 'Isolate', 'Context', 'Script', 'Module', 'Callback', 'Reference', 'ExternalCopy', 'process', 'require', 'module', 'exports', '__dirname', '__filename', - '__brokerRef', '__broker', '__callSimBroker' + '__fetchRef', '__brokerRef', '__broker', '__callSimBroker' ]; for (const name of undefined_globals) { try { From b763250bbe02624c671775bf27e24389e5be2c08 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 15 Aug 2026 18:41:46 -0700 Subject: [PATCH 2/3] test(sandbox): scope the hardening assertions to each execution path --- .../isolated-vm-worker-hardening.test.ts | 88 ++++++++++++------- 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/apps/sim/lib/execution/isolated-vm-worker-hardening.test.ts b/apps/sim/lib/execution/isolated-vm-worker-hardening.test.ts index 89833315f2f..7eb9797c8ca 100644 --- a/apps/sim/lib/execution/isolated-vm-worker-hardening.test.ts +++ b/apps/sim/lib/execution/isolated-vm-worker-hardening.test.ts @@ -3,8 +3,12 @@ * * Guards the isolate hardening contract in `isolated-vm-worker.cjs`: no raw * `ivm.Reference` host bridge may survive as an isolate global once user code - * runs. The bootstrap must capture each bridge in a closure and list its global - * name in `undefined_globals`. + * runs. Each bootstrap must capture its bridges in a closure and list their + * global names in its own `undefined_globals`. + * + * The two execution paths harden independently, so every assertion is scoped to + * one path's source slice — a bridge installed by `executeCode` but undefined + * only by `executeTask` must still fail. */ import { readFileSync } from 'node:fs' import { join } from 'node:path' @@ -12,41 +16,65 @@ import { describe, expect, it } from 'vitest' const WORKER_SOURCE = readFileSync(join(__dirname, 'isolated-vm-worker.cjs'), 'utf8') -/** Global names bound to a raw `ivm.Reference` (as opposed to an `ivm.Callback`). */ -const REFERENCE_BRIDGES = [ - '__fetchRef', - '__brokerRef', - '__setTimeoutRef', - '__clearTimeoutRef', - '__setIntervalRef', +const EXECUTE_CODE_MARKER = 'async function executeCode(' +const EXECUTE_TASK_MARKER = 'async function executeTask(' + +/** + * Source of one execution path, from its function declaration to the start of + * the next one (or end of file for the last path). + */ +function pathSource(marker: string, nextMarker?: string): string { + const start = WORKER_SOURCE.indexOf(marker) + expect(start, `${marker} not found — worker layout changed`).toBeGreaterThan(-1) + const end = nextMarker ? WORKER_SOURCE.indexOf(nextMarker, start) : WORKER_SOURCE.length + expect(end, `${nextMarker} not found — worker layout changed`).toBeGreaterThan(start) + return WORKER_SOURCE.slice(start, end) +} + +const EXECUTION_PATHS = [ + { name: 'executeCode', source: pathSource(EXECUTE_CODE_MARKER, EXECUTE_TASK_MARKER) }, + { name: 'executeTask', source: pathSource(EXECUTE_TASK_MARKER) }, ] -function hardeningLists(): string[] { - const lists = WORKER_SOURCE.match(/const undefined_globals = \[[\s\S]*?\]/g) - expect(lists).not.toBeNull() - return lists as string[] +/** + * Globals bound to a raw `ivm.Reference`. The worker names these `__*Ref`; + * `ivm.Callback` bridges (`__log`, `__textEncode`, …) are plain isolate + * functions that expose no host handle and are deliberately not matched. + */ +function referenceBridges(source: string): string[] { + return [...source.matchAll(/jail\.set\('(__\w+Ref)'/g)].map((match) => match[1]) +} + +function hardeningList(source: string): string { + const list = source.match(/const undefined_globals = \[[\s\S]*?\]/) + expect(list, 'no undefined_globals hardening list in this execution path').not.toBeNull() + return (list as RegExpMatchArray)[0] } describe('isolated-vm worker hardening', () => { - it('undefines every ivm.Reference bridge it installs as a global', () => { - const lists = hardeningLists() - expect(lists.length).toBeGreaterThanOrEqual(2) - - for (const bridge of REFERENCE_BRIDGES) { - const installed = WORKER_SOURCE.includes(`jail.set('${bridge}'`) - if (!installed) continue - const undefinedSomewhere = lists.some((list) => list.includes(`'${bridge}'`)) - expect(undefinedSomewhere, `${bridge} is set as an isolate global but never undefined`).toBe( - true - ) - } - }) + it.each(EXECUTION_PATHS)( + '$name undefines every ivm.Reference bridge it installs', + ({ name, source }) => { + const bridges = referenceBridges(source) + expect( + bridges.length, + `${name} installs no __*Ref bridges — detection is stale` + ).toBeGreaterThan(0) - it('keeps the isolated-vm escape globals in every hardening list', () => { - for (const list of hardeningLists()) { - for (const name of ['Isolate', 'Context', 'Script', 'Reference', 'ExternalCopy']) { - expect(list).toContain(`'${name}'`) + const list = hardeningList(source) + for (const bridge of bridges) { + expect( + list.includes(`'${bridge}'`), + `${name} sets ${bridge} as an isolate global but its hardening list omits it` + ).toBe(true) } } + ) + + it.each(EXECUTION_PATHS)('$name undefines the isolated-vm escape globals', ({ source }) => { + const list = hardeningList(source) + for (const name of ['Isolate', 'Context', 'Script', 'Reference', 'ExternalCopy']) { + expect(list).toContain(`'${name}'`) + } }) }) From 99e4a616095134bb979e06ca70138b35c361a1f4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 15 Aug 2026 18:47:16 -0700 Subject: [PATCH 3/3] fix(sandbox): preserve the fetch global's property attributes --- apps/sim/lib/execution/isolated-vm-worker.cjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/execution/isolated-vm-worker.cjs b/apps/sim/lib/execution/isolated-vm-worker.cjs index 210c55b796d..ac19d487b21 100644 --- a/apps/sim/lib/execution/isolated-vm-worker.cjs +++ b/apps/sim/lib/execution/isolated-vm-worker.cjs @@ -315,7 +315,7 @@ async function executeCode(request, executionId) { // can undefine the global without breaking fetch(). (() => { const __fetch = globalThis.__fetchRef; - globalThis.fetch = async function fetch(url, options) { + const fetchImpl = async function fetch(url, options) { let optionsJson; if (options) { try { @@ -360,6 +360,14 @@ async function executeCode(request, executionId) { arrayBuffer: async () => { throw new Error('arrayBuffer() not supported in sandbox'); }, }; }; + // Same property attributes a top-level \`function fetch\` declaration + // produced, so user code sees an unchanged global. + Object.defineProperty(global, 'fetch', { + value: fetchImpl, + writable: true, + enumerable: true, + configurable: false + }); })(); const sim = (() => {