fix(sandbox): undefine the raw fetch host bridge before user code runs (#6761)

* fix(sandbox): undefine the raw fetch host bridge before user code runs

* test(sandbox): scope the hardening assertions to each execution path

* fix(sandbox): preserve the fetch global's property attributes
This commit is contained in:
Waleed
2026-08-15 18:53:35 -07:00
committed by GitHub
parent 8a44621382
commit 6e5c337527
2 changed files with 98 additions and 5 deletions
@@ -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}'`)
}
})
})
+18 -5
View File
@@ -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 {
@@ -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,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;
@@ -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 {