Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions apps/sim/lib/execution/isolated-vm-worker-hardening.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @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. 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'
import { describe, expect, it } from 'vitest'

const WORKER_SOURCE = readFileSync(join(__dirname, 'isolated-vm-worker.cjs'), 'utf8')

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) },
]

/**
* 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.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)

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}'`)
}
})
})
23 changes: 18 additions & 5 deletions apps/sim/lib/execution/isolated-vm-worker.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
const fetchImpl = async function fetch(url, options) {
let optionsJson;
if (options) {
try {
Expand All @@ -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);
Expand Down Expand Up @@ -355,7 +359,16 @@ 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'); },
};
}
};
// 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 = (() => {
const broker = __brokerRef;
Expand Down Expand Up @@ -408,7 +421,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 {
Expand Down
Loading