Enclave has a sandbox escape via infinite recursion and error objects
Description
Enclave is a secure JavaScript sandbox designed for safe AI agent code execution. Prior to 2.10.1, the existing layers of security in enclave-vm are insufficient: The AST sanitization can be bypassed with dynamic property accesses, the hardening of the error objects does not cover the peculiar behavior or the vm module and the function constructor access prevention can be side-stepped by leveraging host object references. This vulnerability is fixed in 2.10.1.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
enclave-vmnpm | <= 2.7.0 | — |
@enclave-vm/corenpm | < 2.10.1 | 2.10.1 |
Affected products
1- Range: < 2.10.1
Patches
12fcf5da81e7efeat: implement security hardening measures against stack overflow escape attacks (#47)
6 files changed · +4002 −2
.github/workflows/push.yml+6 −0 modified@@ -90,6 +90,9 @@ jobs: libs/*/dist key: build-${{ github.sha }} + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Set Nx SHAs uses: nrwl/nx-set-shas@v4 @@ -132,6 +135,9 @@ jobs: node-version-file: ".nvmrc" cache: "yarn" + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Restore build artifacts uses: actions/cache/restore@v4 with:
libs/ast/src/rules/resource-exhaustion.rule.ts+76 −0 modified@@ -249,6 +249,20 @@ export class ResourceExhaustionRule implements ValidationRule { }); } } + + // Detect computed access via function calls that could produce dangerous strings + // e.g., obj[String(['constructor'])] or obj[['proto'].toString()] + // CVE-2023-29017 style bypass: String(['constructor']) coerces array to 'constructor' + if (node.computed && node.property.type === 'CallExpression') { + if (this.isSuspiciousCoercionCall(node.property)) { + context.report({ + code: 'CONSTRUCTOR_ACCESS', + message: + 'Computed property access via coercion function is not allowed (potential sandbox escape vector)', + location: this.getLocation(node), + }); + } + } }, // Detect suspicious variable assignments that build "constructor" @@ -277,6 +291,68 @@ export class ResourceExhaustionRule implements ValidationRule { return result === 'constructor' || result === 'prototype' || result === '__proto__'; } + /** + * Check if a call expression could be coercing a dangerous string + * Detects patterns like: + * - String(['constructor']) - array coercion + * - String.fromCharCode(...) - character code building + * - ['constructor'].toString() - array method coercion + * - ['constructor'].join('') - array join coercion + */ + private isSuspiciousCoercionCall(node: any): boolean { + const dangerousStrings = ['constructor', '__proto__', 'prototype']; + + // String(['constructor']) - String() called with array containing dangerous string + if (node.callee.type === 'Identifier' && node.callee.name === 'String') { + if (node.arguments.length === 1) { + const arg = node.arguments[0]; + if (arg.type === 'ArrayExpression' && arg.elements.length === 1) { + const element = arg.elements[0]; + if (element?.type === 'Literal' && typeof element.value === 'string') { + const value = element.value.toLowerCase(); + if (dangerousStrings.includes(value)) { + return true; + } + } + } + } + } + + // String.fromCharCode(...) or String['fromCharCode'](...) - always suspicious in computed property context + if ( + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'String' + ) { + const property = node.callee.property; + const isFromCharCode = + (property.type === 'Identifier' && property.name === 'fromCharCode') || + ((property.type === 'Literal' || property.type === 'StringLiteral') && property.value === 'fromCharCode'); + if (isFromCharCode) { + return true; + } + } + + // ['constructor'].toString() or ['constructor'].join('') + if (node.callee.type === 'MemberExpression' && node.callee.object.type === 'ArrayExpression') { + const arr = node.callee.object; + if (arr.elements.length === 1 && arr.elements[0]?.type === 'Literal') { + const value = String(arr.elements[0].value).toLowerCase(); + if (dangerousStrings.includes(value)) { + // Only flag actual coercion methods that convert array to string + if ( + node.callee.property.type === 'Identifier' && + (node.callee.property.name === 'toString' || node.callee.property.name === 'join') + ) { + return true; + } + } + } + } + + return false; + } + /** * Try to evaluate a string concatenation expression * Returns the result if it's a simple string concat, or null if too complex
libs/core/src/double-vm/parent-vm-bootstrap.ts+200 −2 modified@@ -1534,8 +1534,110 @@ ${stackTraceHardeningCode} // Remove dangerous globals from inner VM (AFTER memory patching) ${sanitizeContextCode} - // Freeze built-in prototypes to prevent prototype pollution - // and cut off constructor chain access for sandbox escape prevention + // ============================================================ + // PHASE 2 HARDENING: CVE-2023-29017 Stack Overflow Escape Defense + // ============================================================ + // + // CRITICAL: Hardening MUST run BEFORE prototypes are frozen! + // Otherwise Object.defineProperty calls will silently fail on frozen prototypes. + // + // Defense-in-depth against prototype chain escape via stack overflow errors: + // 1. __proto__ shadowing to block prototype chain traversal + // 2. Legacy method blocking (__lookupGetter__, etc.) + // 3. Error constructor wrapping to secure V8-generated errors + // 4. Then freeze all prototypes to lock in hardening + + // HARDENING 1: Shadow __proto__ on PARENT VM error prototypes with null + // This prevents e["__proto__"]["__proto__"] traversal attacks by making + // __proto__ return null instead of the actual prototype chain. + // MUST run before freeze! + (function() { + var errorProtos = [ + Error.prototype, + TypeError.prototype, + RangeError.prototype, + SyntaxError.prototype, + ReferenceError.prototype, + URIError.prototype, + EvalError.prototype + ]; + for (var i = 0; i < errorProtos.length; i++) { + // Shadow __proto__ with a null-returning getter + // This blocks: error["__proto__"]["__proto__"]["__proto__"] attacks + Object.defineProperty(errorProtos[i], '__proto__', { + get: function() { return null; }, + set: function() { /* silently ignore */ }, + configurable: false, + enumerable: false + }); + } + })(); + + // HARDENING 2: Shadow __proto__ in INNER VM error prototypes + // MUST run before freeze! + (function() { + var shadowProtoCode = + '(function() {' + + ' var errorProtos = [' + + ' Error.prototype,' + + ' TypeError.prototype,' + + ' RangeError.prototype,' + + ' SyntaxError.prototype,' + + ' ReferenceError.prototype,' + + ' URIError.prototype,' + + ' EvalError.prototype' + + ' ];' + + ' for (var i = 0; i < errorProtos.length; i++) {' + + ' Object.defineProperty(errorProtos[i], "__proto__", {' + + ' get: function() { return null; },' + + ' set: function() {},' + + ' configurable: false,' + + ' enumerable: false' + + ' });' + + ' }' + + '})();'; + var shadowScript = new vm.Script(shadowProtoCode); + shadowScript.runInContext(innerContext); + })(); + + // HARDENING 3: Block legacy prototype manipulation methods in PARENT VM + // These deprecated methods (__lookupGetter__, __lookupSetter__, __defineGetter__, __defineSetter__) + // can be used to bypass frozen prototype protections. + // MUST run before freeze! + (function() { + var legacyMethods = ['__lookupGetter__', '__lookupSetter__', '__defineGetter__', '__defineSetter__']; + for (var i = 0; i < legacyMethods.length; i++) { + Object.defineProperty(Object.prototype, legacyMethods[i], { + value: function() { return undefined; }, + writable: false, + configurable: false, + enumerable: false + }); + } + })(); + + // HARDENING 4: Block legacy prototype methods in INNER VM + // MUST run before freeze! + (function() { + var blockLegacyCode = + '(function() {' + + ' var methods = ["__lookupGetter__", "__lookupSetter__", "__defineGetter__", "__defineSetter__"];' + + ' for (var i = 0; i < methods.length; i++) {' + + ' Object.defineProperty(Object.prototype, methods[i], {' + + ' value: function() { return undefined; },' + + ' writable: false,' + + ' configurable: false,' + + ' enumerable: false' + + ' });' + + ' }' + + '})();'; + var blockScript = new vm.Script(blockLegacyCode); + blockScript.runInContext(innerContext); + })(); + + // ============================================================ + // PROTOTYPE FREEZING: Lock in hardening by freezing all prototypes + // ============================================================ // // We freeze prototypes in TWO places: // 1. PARENT VM prototypes - because SafeObject.prototype = Object.prototype uses parent's prototype @@ -1557,6 +1659,8 @@ ${stackTraceHardeningCode} Object.freeze(RangeError.prototype); Object.freeze(SyntaxError.prototype); Object.freeze(ReferenceError.prototype); + Object.freeze(URIError.prototype); + Object.freeze(EvalError.prototype); Object.freeze(Promise.prototype); // Freeze INNER VM prototypes (used by literals like '', [], etc.) @@ -1574,11 +1678,64 @@ ${stackTraceHardeningCode} 'Object.freeze(RangeError.prototype);' + 'Object.freeze(SyntaxError.prototype);' + 'Object.freeze(ReferenceError.prototype);' + + 'Object.freeze(URIError.prototype);' + + 'Object.freeze(EvalError.prototype);' + 'Object.freeze(Promise.prototype);'; var freezeScript = new vm.Script(freezeCode); freezeScript.runInContext(innerContext); })(); + // HARDENING 5: Wrap error constructors to freeze instances + // V8-generated errors (like stack overflow RangeError) are created internally, + // not via the constructor. But wrapping provides defense-in-depth for any + // errors that ARE created via constructors. + // NOTE: This can run after freeze because it replaces global constructors, not prototype properties + (function() { + var wrapErrorCode = + '(function() {' + + ' var origError = Error;' + + ' var origTypeError = TypeError;' + + ' var origRangeError = RangeError;' + + ' var origSyntaxError = SyntaxError;' + + ' var origReferenceError = ReferenceError;' + + ' var origEvalError = EvalError;' + + ' var origURIError = URIError;' + + ' function wrapErrorCtor(OrigCtor, name) {' + + ' var WrappedCtor = function(msg) {' + + ' var err;' + + ' if (new.target) {' + + ' err = new OrigCtor(msg);' + + ' } else {' + + ' err = OrigCtor(msg);' + + ' }' + + ' try { Object.freeze(err); } catch (e) {}' + + ' return err;' + + ' };' + + ' WrappedCtor.prototype = OrigCtor.prototype;' + + ' try { Object.setPrototypeOf(WrappedCtor, OrigCtor); } catch (e) {}' + + ' try { Object.freeze(WrappedCtor); } catch (e) {}' + + ' return WrappedCtor;' + + ' }' + + ' try { Error = wrapErrorCtor(origError, "Error"); } catch (e) {}' + + ' try { TypeError = wrapErrorCtor(origTypeError, "TypeError"); } catch (e) {}' + + ' try { RangeError = wrapErrorCtor(origRangeError, "RangeError"); } catch (e) {}' + + ' try { SyntaxError = wrapErrorCtor(origSyntaxError, "SyntaxError"); } catch (e) {}' + + ' try { ReferenceError = wrapErrorCtor(origReferenceError, "ReferenceError"); } catch (e) {}' + + ' try { EvalError = wrapErrorCtor(origEvalError, "EvalError"); } catch (e) {}' + + ' try { URIError = wrapErrorCtor(origURIError, "URIError"); } catch (e) {}' + + '})();'; + try { + var wrapScript = new vm.Script(wrapErrorCode); + wrapScript.runInContext(innerContext); + } catch (e) { /* ignore */ } + })(); + + // NOTE: HARDENING 6 and 7 (Object.getPrototypeOf / Reflect.getPrototypeOf wrappers) + // were removed as they break legitimate internal functionality like __safe_template. + // The existing defenses (prototype freezing, __proto__ shadowing, error constructor + // wrapping, codeGeneration.strings=false) provide sufficient protection against + // the CVE-2023-29017 attack pattern. + // Inject safe runtime functions (non-writable, non-configurable) // Wrap with secure proxy to block dangerous property access var safeRuntime = { @@ -1763,6 +1920,47 @@ ${stackTraceHardeningCode} // Execute User Code // ============================================================ + // HARDENING 4: Runtime prototype verification before user code execution + // Verifies that critical prototypes are still frozen. If any have been + // unfrozen (which shouldn't be possible but defense-in-depth), abort. + (function() { + var criticalPrototypes = [ + { name: 'Object.prototype', proto: Object.prototype }, + { name: 'Array.prototype', proto: Array.prototype }, + { name: 'Function.prototype', proto: Function.prototype }, + { name: 'Error.prototype', proto: Error.prototype }, + { name: 'RangeError.prototype', proto: RangeError.prototype } + ]; + for (var i = 0; i < criticalPrototypes.length; i++) { + var item = criticalPrototypes[i]; + if (!Object.isFrozen(item.proto)) { + throw new Error('SECURITY VIOLATION: ' + item.name + ' is not frozen. Aborting execution.'); + } + } + })(); + + // HARDENING 5: Runtime verification in INNER VM + (function() { + var verifyCode = + '(function() {' + + ' var protos = [Object.prototype, Array.prototype, Error.prototype, RangeError.prototype];' + + ' for (var i = 0; i < protos.length; i++) {' + + ' if (!Object.isFrozen(protos[i])) {' + + ' throw new Error("SECURITY VIOLATION: Inner VM prototype not frozen");' + + ' }' + + ' }' + + '})();'; + try { + var verifyScript = new vm.Script(verifyCode); + verifyScript.runInContext(innerContext); + } catch (e) { + if (e.message && e.message.indexOf('SECURITY VIOLATION') >= 0) { + throw e; + } + /* ignore other errors */ + } + })(); + var userCode = ${JSON.stringify(userCode)}; // IMPORTANT: For stack overflow errors (RangeError: Maximum call stack size exceeded), // Node/V8 may ignore Error.prepareStackTrace when it was installed by a different Script
libs/core/src/__tests__/enclave.advanced-escape.spec.ts+1782 −0 added@@ -0,0 +1,1782 @@ +/** + * Advanced Sandbox Escape Prevention Tests + * + * Comprehensive tests covering CVEs and research findings: + * - Promise callback sanitization (CVE-2026-22709, GHSA-99p7-6v5w-7xg8) + * - Custom inspect function (CVE-2023-37903, GHSA-g644-9gfx-q4q4) + * - Exception sanitization (CVE-2023-29199, CVE-2023-30547) + * - Proxy-spec host object creation (CVE-2023-32314, GHSA-whpj-8f3w-67p5) + * - Stack trace manipulation (CVE-2022-36067) + * - SandDriller findings (unwrapped VM exceptions, import payloads) + * + * Reference: Security research combining web research and Codex analysis on + * JavaScript sandbox escape vulnerabilities affecting vm2, isolated-vm, and Node.js vm module. + */ + +import { Enclave } from '../enclave'; +import type { ToolHandler } from '../types'; + +describe('Advanced Sandbox Escape Prevention', () => { + describe('ATK-PROMISE: Promise Callback Sanitization (CVE-2026-22709, GHSA-99p7-6v5w-7xg8)', () => { + /** + * CVE-2026-22709 / GHSA-99p7-6v5w-7xg8: Promise callback sanitization bypass + * + * Attack Mechanism: + * - Async functions return globalPromise objects, not localPromise + * - globalPromise.prototype.then/catch are not properly sanitized + * - Attacker can override Function.prototype.call to intercept callback invocations + * - @@species accessor property can bypass Promise handler sanitization + */ + + it('ATK-PROMISE-01: should not leak host context via async function Promise', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + // Test that async function returns use sandbox Promise, not global Promise + async function testAsync() { + return 'test'; + } + + const promise = testAsync(); + const results = []; + + // Check Promise constructor chain + try { + const PromiseCtor = promise.constructor; + results.push({ test: 'promise_constructor', type: typeof PromiseCtor }); + + if (typeof PromiseCtor === 'function') { + try { + const F = PromiseCtor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_VIA_PROMISE_CTOR"'); + const result = fn(); + results.push({ test: 'promise_ctor_escape', escaped: result === 'ESCAPED_VIA_PROMISE_CTOR' }); + } else { + results.push({ test: 'promise_ctor_escape', blocked: true, reason: 'not_function' }); + } + } catch (e) { + results.push({ test: 'promise_ctor_escape', blocked: true, error: e.name }); + } + } + } catch (e) { + results.push({ test: 'promise_constructor', blocked: true, error: e.name }); + } + + return results; + `; + + const result = + await enclave.run<Array<{ test: string; escaped?: boolean; blocked?: boolean; error?: string }>>(code); + + if (result.success && Array.isArray(result.value)) { + // No escape should have succeeded + for (const r of result.value) { + expect(r.escaped).not.toBe(true); + } + } else { + // Security failure is acceptable + expect(result.error).toBeDefined(); + } + + enclave.dispose(); + }, 15000); + + it('ATK-PROMISE-02: should sanitize .then() callback context', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Try to escape via .then() callback context + await Promise.resolve().then(function callback() { + try { + // Try to access Function via callback's constructor chain + const F = callback.constructor; + if (typeof F === 'function') { + try { + const fn = F('return "ESCAPED_VIA_THEN_CALLBACK"'); + results.push({ escaped: fn() === 'ESCAPED_VIA_THEN_CALLBACK' }); + } catch (e) { + results.push({ blocked: true, error: e.name }); + } + } else { + results.push({ blocked: true, reason: 'callback_ctor_not_function' }); + } + } catch (e) { + results.push({ blocked: true, error: e.name }); + } + }); + + return results; + `; + + const result = await enclave.run<Array<{ escaped?: boolean; blocked?: boolean; error?: string }>>(code); + + if (result.success && Array.isArray(result.value)) { + for (const r of result.value) { + expect(r.escaped).not.toBe(true); + } + } else { + // Security failure (validation blocked) is acceptable + expect(result.error).toBeDefined(); + } + + enclave.dispose(); + }, 15000); + + it('ATK-PROMISE-03: should sanitize .catch() callback context', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Try to escape via .catch() callback context + await Promise.reject(new Error('test')).catch(function errorHandler(e) { + try { + // Try to access Function via errorHandler's constructor chain + const F = errorHandler.constructor; + if (typeof F === 'function') { + try { + const fn = F('return "ESCAPED_VIA_CATCH_CALLBACK"'); + results.push({ escaped: fn() === 'ESCAPED_VIA_CATCH_CALLBACK' }); + } catch (e) { + results.push({ blocked: true, error: e.name }); + } + } else { + results.push({ blocked: true, reason: 'handler_ctor_not_function' }); + } + } catch (e) { + results.push({ blocked: true, error: e.name }); + } + }); + + return results; + `; + + const result = await enclave.run<Array<{ escaped?: boolean; blocked?: boolean; error?: string }>>(code); + + if (result.success && Array.isArray(result.value)) { + for (const r of result.value) { + expect(r.escaped).not.toBe(true); + } + } else { + expect(result.error).toBeDefined(); + } + + enclave.dispose(); + }, 15000); + + it('ATK-PROMISE-04: should block Symbol.species override on Promise', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Try to override Symbol.species on Promise + try { + const originalSpecies = Promise[Symbol.species]; + results.push({ test: 'original_species', type: typeof originalSpecies }); + + // Attempt to set Symbol.species + try { + Promise[Symbol.species] = class MaliciousPromise { + constructor(exec) { + exec(() => {}, (e) => { + // Try to escape via rejection handler + }); + } + }; + results.push({ test: 'species_override', success: true }); + } catch (e) { + results.push({ test: 'species_override', blocked: true, error: e.name }); + } + } catch (e) { + results.push({ test: 'species_access', blocked: true, error: e.name }); + } + + return results; + `; + + const result = await enclave.run<Array<{ test: string; blocked?: boolean; success?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + // Symbol.species override should be blocked (prototype frozen) + const overrideResult = result.value.find((r) => r.test === 'species_override'); + if (overrideResult) { + expect(overrideResult.blocked).toBe(true); + } + } + + enclave.dispose(); + }, 15000); + + it('ATK-PROMISE-05: should not allow Function access via rejection handler', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Try multiple Promise rejection escape vectors + const testPromise = new Promise((resolve, reject) => { + reject(new Error('test rejection')); + }); + + await testPromise.catch((e) => { + // Attempt 1: Via error constructor chain + try { + const F = e.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_VIA_ERROR_CTOR"'); + results.push({ method: 'error_ctor', escaped: fn() === 'ESCAPED_VIA_ERROR_CTOR' }); + } else { + results.push({ method: 'error_ctor', blocked: true, reason: 'not_function' }); + } + } catch (err) { + results.push({ method: 'error_ctor', blocked: true, error: err.name }); + } + + // Attempt 2: Via error toString + try { + const F = e.toString.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_VIA_TOSTRING"'); + results.push({ method: 'toString', escaped: fn() === 'ESCAPED_VIA_TOSTRING' }); + } else { + results.push({ method: 'toString', blocked: true, reason: 'not_function' }); + } + } catch (err) { + results.push({ method: 'toString', blocked: true, error: err.name }); + } + }); + + return results; + `; + + const result = await enclave.run<Array<{ method: string; escaped?: boolean; blocked?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + for (const r of result.value) { + expect(r.escaped).not.toBe(true); + } + } else { + expect(result.error).toBeDefined(); + } + + enclave.dispose(); + }, 15000); + + it('ATK-PROMISE-06: should use sandbox Promise for async returns, not global Promise', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Test that Promise.prototype is frozen (sandbox Promise) + try { + Promise.prototype.maliciousMethod = function() { return 'pwned'; }; + results.push({ test: 'prototype_modification', success: true }); + } catch (e) { + results.push({ test: 'prototype_modification', blocked: true, error: e.name }); + } + + // Verify async function Promise inherits from sandbox Promise.prototype + async function testAsync() { return 42; } + const p = testAsync(); + const protoIsFrozen = Object.isFrozen(Object.getPrototypeOf(p)); + results.push({ test: 'async_promise_proto_frozen', frozen: protoIsFrozen }); + + return results; + `; + + const result = + await enclave.run<Array<{ test: string; blocked?: boolean; success?: boolean; frozen?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + // Prototype modification should be blocked (frozen) + const modResult = result.value.find((r) => r.test === 'prototype_modification'); + if (modResult) { + expect(modResult.blocked).toBe(true); + } + + // Promise prototype should be frozen + const frozenResult = result.value.find((r) => r.test === 'async_promise_proto_frozen'); + if (frozenResult) { + expect(frozenResult.frozen).toBe(true); + } + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-INSPECT: Custom Inspect Function (CVE-2023-37903, GHSA-g644-9gfx-q4q4)', () => { + /** + * CVE-2023-37903 / GHSA-g644-9gfx-q4q4: Custom inspect function escape + * + * Attack Mechanism: + * - Define custom inspect via Symbol.for('nodejs.util.inspect.custom') + * - When error.stack is accessed, V8 calls toString on error name + * - If name is Symbol, TypeError is thrown in host context + * - Access host error through .catch() handler + */ + + it('ATK-INSPECT-01: should block or sandbox Symbol.for() access', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Test Symbol.for() availability + try { + const sym = Symbol.for('test'); + results.push({ test: 'symbol_for_access', available: true, type: typeof sym }); + + // Try to create the dangerous inspect symbol + const inspectSym = Symbol.for('nodejs.util.inspect.custom'); + results.push({ test: 'inspect_symbol', created: true }); + } catch (e) { + results.push({ test: 'symbol_for_access', blocked: true, error: e.name }); + } + + return results; + `; + + const result = await enclave.run<Array<{ test: string; blocked?: boolean; available?: boolean }>>(code); + + // Symbol.for is a standard feature but the test documents current behavior + // The important thing is that inspect.custom cannot be used for escape + if (result.success && Array.isArray(result.value)) { + // Document the behavior (we'll verify escape is blocked in next test) + expect(result.value.length).toBeGreaterThan(0); + } + + enclave.dispose(); + }, 15000); + + it('ATK-INSPECT-02: should not allow nodejs.util.inspect.custom symbol for escape', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Create object with custom inspect function + try { + const customInspect = Symbol.for('nodejs.util.inspect.custom'); + let escapeTriggered = false; + + const maliciousObj = { + [customInspect]: function() { + escapeTriggered = true; + try { + // Try to access Function constructor from inspect context + const F = this.constructor.constructor; + if (typeof F === 'function') { + return F('return "ESCAPED_VIA_INSPECT"')(); + } + } catch (e) {} + return 'inspect called'; + } + }; + + // Try to trigger the inspect function + // In sandbox, this should NOT trigger host code execution + try { + const str = String(maliciousObj); + results.push({ test: 'string_coercion', result: str, escapeTriggered }); + } catch (e) { + results.push({ test: 'string_coercion', blocked: true, error: e.name }); + } + + } catch (e) { + results.push({ test: 'inspect_setup', blocked: true, error: e.name }); + } + + return results; + `; + + const result = await enclave.run<Array<{ test: string; escaped?: boolean; blocked?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + // Escape should not have worked + for (const r of result.value) { + if ('result' in r) { + expect(r.result).not.toBe('ESCAPED_VIA_INSPECT'); + } + } + } + + enclave.dispose(); + }, 15000); + + it('ATK-INSPECT-03: should not expose host objects via custom toString', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Create object with malicious toString + const maliciousObj = { + toString: function() { + try { + // Try various escape vectors from toString context + const F = this.toString.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_VIA_TOSTRING"'); + return fn(); + } + } catch (e) { + results.push({ method: 'toString_ctor', blocked: true, error: e.name }); + } + return '[safe object]'; + } + }; + + // Trigger toString + const str = '' + maliciousObj; + results.push({ test: 'result', value: str, escaped: str === 'ESCAPED_VIA_TOSTRING' }); + + return results; + `; + + const result = await enclave.run<Array<{ test?: string; escaped?: boolean; blocked?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + const finalResult = result.value.find((r) => r.test === 'result'); + if (finalResult) { + expect(finalResult.escaped).not.toBe(true); + } + } else { + expect(result.error).toBeDefined(); + } + + enclave.dispose(); + }, 15000); + + it('ATK-INSPECT-04: should not execute sandbox code when host logs sandbox objects', async () => { + // This tests that console.log does not trigger escape vectors + let hostCodeExecuted = false; + + const enclave = new Enclave({ + timeout: 5000, + allowFunctionsInGlobals: true, + globals: { + __hostSentinel__: () => { + hostCodeExecuted = true; + return 'HOST_EXECUTED'; + }, + }, + }); + + const code = ` + // Create object that tries to call sentinel when logged + const maliciousObj = { + [Symbol.for('nodejs.util.inspect.custom')]: function() { + try { + return __hostSentinel__(); + } catch (e) { + return 'inspect_failed'; + } + }, + toString: function() { + try { + return __hostSentinel__(); + } catch (e) { + return 'toString_failed'; + } + } + }; + + // Log the object - this should NOT call the sentinel in host context + console.log(maliciousObj); + console.log('Object:', maliciousObj); + + return 'logged'; + `; + + const result = await enclave.run(code); + + // The key assertion: host sentinel should not have been called + // (unless explicitly through the allowed globals path) + expect(hostCodeExecuted).toBe(false); + expect(result).toBeDefined(); + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-EXCEPT: Exception Sanitization (CVE-2023-29199, CVE-2023-30547, SandDriller)', () => { + /** + * CVE-2023-29199 / CVE-2023-30547: Exception sanitization bypass + * + * Attack Mechanism: + * - Raise unsanitized host exception inside exception handlers + * - Host exceptions retain host-context prototype chains + * - Traverse prototype chain to Function constructor + * + * SandDriller Research (USENIX Security 2023): + * - VM internals can spawn unwrapped exceptions during stack trace handling + * - These bypass membranes and expose host objects + */ + + it('ATK-EXCEPT-01: should wrap tool handler exceptions', async () => { + // Tool handler that throws an error + const toolHandler: ToolHandler = async (name, args) => { + if (name === 'throw_error') { + throw new Error('Tool error with sensitive data'); + } + return { ok: true }; + }; + + const enclave = new Enclave({ toolHandler, timeout: 5000 }); + const code = ` + const results = []; + + try { + await callTool('throw_error', {}); + } catch (e) { + // Attempt to escape via the caught exception + try { + const F = e.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_VIA_TOOL_ERROR"'); + results.push({ escaped: fn() === 'ESCAPED_VIA_TOOL_ERROR' }); + } else { + results.push({ blocked: true, reason: 'not_function' }); + } + } catch (escapeErr) { + results.push({ blocked: true, error: escapeErr.name }); + } + + // Also try __proto__ chain + try { + const proto = e.__proto__.__proto__.__proto__; + results.push({ protoChain: proto === null ? 'null' : typeof proto }); + } catch (protoErr) { + results.push({ protoChain: 'blocked', error: protoErr.name }); + } + } + + return results; + `; + + const result = await enclave.run<Array<{ escaped?: boolean; blocked?: boolean; protoChain?: string }>>(code); + + if (result.success && Array.isArray(result.value)) { + // No escape should have succeeded + for (const r of result.value) { + expect(r.escaped).not.toBe(true); + } + } + + enclave.dispose(); + }, 15000); + + it('ATK-EXCEPT-02: should not expose host exception prototype chain', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Create various exceptions and test their prototype chains + const exceptions = [ + new Error('test'), + new TypeError('test'), + new RangeError('test'), + ]; + + for (const e of exceptions) { + // Test __proto__ chain + try { + const proto1 = e.__proto__; + const proto2 = proto1?.__proto__; + const proto3 = proto2?.__proto__; + results.push({ + errorType: e.name, + proto1IsNull: proto1 === null, + proto2Value: proto1 === null ? 'n/a' : (proto2 === null ? 'null' : typeof proto2), + proto3Value: proto2 === null ? 'n/a' : (proto3 === null ? 'null' : typeof proto3), + }); + } catch (err) { + results.push({ errorType: e.name, blocked: true, error: err.name }); + } + } + + return results; + `; + + const result = await enclave.run<Array<{ errorType: string; proto1IsNull?: boolean; blocked?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + // __proto__ should return null (shadowed) for all error types + for (const r of result.value) { + if (!r.blocked) { + expect(r.proto1IsNull).toBe(true); + } + } + } + + enclave.dispose(); + }, 15000); + + it('ATK-EXCEPT-03: should sanitize VM internal errors', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Try to trigger various internal VM errors + const testCases = [ + { + name: 'stack_overflow', + trigger: () => { + const overflow = () => { overflow(); }; + overflow(); + } + }, + { + name: 'type_error', + trigger: () => { + null.property; + } + }, + { + name: 'range_error', + trigger: () => { + new Array(-1); + } + } + ]; + + for (const tc of testCases) { + try { + tc.trigger(); + } catch (e) { + // Test escape via the internal error + try { + const F = e.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_VIA_INTERNAL_ERROR"'); + results.push({ + case: tc.name, + escaped: fn() === 'ESCAPED_VIA_INTERNAL_ERROR' + }); + } else { + results.push({ case: tc.name, blocked: true, reason: 'not_function' }); + } + } catch (escapeErr) { + results.push({ case: tc.name, blocked: true, error: escapeErr.name }); + } + } + } + + return results; + `; + + const result = await enclave.run<Array<{ case: string; escaped?: boolean; blocked?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + for (const r of result.value) { + expect(r.escaped).not.toBe(true); + } + } + + enclave.dispose(); + }, 15000); + + it('ATK-EXCEPT-04: should handle unwrapped exceptions from stack trace handling (SandDriller)', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Attempt to trigger unwrapped exception during stack trace handling + // This is based on SandDriller research findings + + // Try 1: Deep recursion with error in catch + let capturedError; + const deepRecurse = (depth) => { + try { + if (depth > 0) deepRecurse(depth - 1); + else throw new Error('bottom'); + } catch (e) { + if (!capturedError) capturedError = e; + throw e; + } + }; + + try { + deepRecurse(100); + } catch (e) { + capturedError = e; + } + + if (capturedError) { + // Try to escape via captured error + try { + const F = capturedError.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_VIA_DEEP_ERROR"'); + results.push({ test: 'deep_recursion', escaped: fn() === 'ESCAPED_VIA_DEEP_ERROR' }); + } else { + results.push({ test: 'deep_recursion', blocked: true }); + } + } catch (e) { + results.push({ test: 'deep_recursion', blocked: true, error: e.name }); + } + } + + return results; + `; + + const result = await enclave.run<Array<{ test: string; escaped?: boolean; blocked?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + for (const r of result.value) { + expect(r.escaped).not.toBe(true); + } + } + + enclave.dispose(); + }, 15000); + + it('ATK-EXCEPT-05: should not expose host objects via async error rejection', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Create async error scenario + async function asyncThrow() { + throw new Error('async error'); + } + + // Try various async error patterns + try { + await asyncThrow(); + } catch (e) { + try { + const F = e.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_VIA_ASYNC_ERROR"'); + results.push({ pattern: 'await_catch', escaped: fn() === 'ESCAPED_VIA_ASYNC_ERROR' }); + } else { + results.push({ pattern: 'await_catch', blocked: true }); + } + } catch (err) { + results.push({ pattern: 'await_catch', blocked: true, error: err.name }); + } + } + + // Also try Promise.reject + await Promise.reject(new Error('rejected')).catch((e) => { + try { + const F = e.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_VIA_REJECT"'); + results.push({ pattern: 'promise_reject', escaped: fn() === 'ESCAPED_VIA_REJECT' }); + } else { + results.push({ pattern: 'promise_reject', blocked: true }); + } + } catch (err) { + results.push({ pattern: 'promise_reject', blocked: true, error: err.name }); + } + }); + + return results; + `; + + const result = await enclave.run<Array<{ pattern: string; escaped?: boolean; blocked?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + for (const r of result.value) { + expect(r.escaped).not.toBe(true); + } + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-PROXY: Proxy-spec Host Object Creation (CVE-2023-32314, GHSA-whpj-8f3w-67p5)', () => { + /** + * CVE-2023-32314 / GHSA-whpj-8f3w-67p5: Proxy specification abuse + * + * Attack Mechanism: + * - Proxy trap invariants can force unexpected host object creation + * - Trap violations may throw errors in host context + * - These errors can expose host prototype chains + */ + + it('ATK-PROXY-01: should block Proxy constructor in STRICT mode', async () => { + const enclave = new Enclave({ securityLevel: 'STRICT', timeout: 5000 }); + const code = ` + try { + const proxy = new Proxy({}, { + get() { return 'trapped'; } + }); + return { proxyCreated: true, result: proxy.anything }; + } catch (e) { + return { proxyBlocked: true, error: e.name, message: e.message }; + } + `; + + const result = await enclave.run<{ proxyCreated?: boolean; proxyBlocked?: boolean }>(code); + + // In STRICT mode, Proxy should be blocked + expect(result.success).toBe(false); + enclave.dispose(); + }, 15000); + + it('ATK-PROXY-02: should block Proxy constructor in SECURE mode', async () => { + const enclave = new Enclave({ securityLevel: 'SECURE', timeout: 5000 }); + const code = ` + try { + const proxy = new Proxy({}, {}); + return { proxyCreated: true }; + } catch (e) { + return { proxyBlocked: true, error: e.name }; + } + `; + + const result = await enclave.run<{ proxyCreated?: boolean; proxyBlocked?: boolean }>(code); + + // In SECURE mode, Proxy should be blocked + expect(result.success).toBe(false); + enclave.dispose(); + }, 15000); + + it('ATK-PROXY-03: should not create unexpected host objects via trap invariants', async () => { + // In STANDARD mode, test that even if Proxy is available, it can't be used for escape + const enclave = new Enclave({ securityLevel: 'STANDARD', timeout: 5000 }); + const code = ` + const results = []; + + // Note: This test runs in STANDARD mode where Proxy might be available + // We test that even with Proxy, escape is not possible + + try { + // Try to create a Proxy that captures host objects + const handler = { + get: function(target, prop, receiver) { + try { + // Try to escape via handler function constructor + const F = arguments.callee.caller; + if (F) { + results.push({ method: 'arguments.callee.caller', available: true }); + } + } catch (e) { + results.push({ method: 'arguments.callee.caller', blocked: true, error: e.name }); + } + return target[prop]; + } + }; + + // Even if Proxy works, verify it doesn't allow escape + const obj = {}; + let proxy; + try { + proxy = new Proxy(obj, handler); + void proxy.test; + results.push({ proxyCreated: true }); + } catch (e) { + results.push({ proxyBlocked: true, error: e.name }); + } + } catch (e) { + results.push({ outerError: e.name }); + } + + return results; + `; + + const result = + await enclave.run< + Array<{ proxyCreated?: boolean; proxyBlocked?: boolean; method?: string; blocked?: boolean }> + >(code); + + // In STANDARD mode Proxy might work or be blocked + // Key assertion: arguments.callee.caller should be blocked (strict mode) + if (result.success && Array.isArray(result.value)) { + const callerResult = result.value.find((r) => r.method === 'arguments.callee.caller'); + if (callerResult) { + expect(callerResult.blocked).toBe(true); + } + } + + enclave.dispose(); + }, 15000); + + it('ATK-PROXY-04: should handle Proxy-like behavior in user objects', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Create object with getter that tries to escape + const maliciousObj = {}; + Object.defineProperty(maliciousObj, 'trap', { + get: function() { + try { + const F = this.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_VIA_GETTER"'); + results.push({ escaped: fn() === 'ESCAPED_VIA_GETTER' }); + } else { + results.push({ blocked: true, reason: 'not_function' }); + } + } catch (e) { + results.push({ blocked: true, error: e.name }); + } + return 'safe value'; + } + }); + + // Trigger the getter + const val = maliciousObj.trap; + results.push({ getterResult: val }); + + return results; + `; + + const result = await enclave.run<Array<{ escaped?: boolean; blocked?: boolean }>>(code); + + // Should either fail validation (defineProperty blocked) or not escape + if (result.success && Array.isArray(result.value)) { + for (const r of result.value) { + expect(r.escaped).not.toBe(true); + } + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-STACK: Stack Trace Manipulation (CVE-2022-36067, SandDriller)', () => { + /** + * CVE-2022-36067 (SandBreak): prepareStackTrace manipulation + * + * Attack Mechanism: + * - Override global Error object with custom prepareStackTrace + * - prepareStackTrace receives CallSite objects with host references + * - CallSite.getFunction() returns host functions + * + * SandDriller: Stack property issues during error handling + */ + + it('ATK-STACK-01: should not allow Error.prepareStackTrace override', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Try to override Error.prepareStackTrace + const originalPST = Error.prepareStackTrace; + results.push({ originalType: typeof originalPST }); + + try { + Error.prepareStackTrace = function(err, stack) { + results.push({ customPST_called: true }); + // Try to access host objects via CallSite + if (stack && stack[0]) { + try { + const fn = stack[0].getFunction(); + results.push({ callSiteFunction: typeof fn }); + } catch (e) { + results.push({ callSiteBlocked: true }); + } + } + return 'custom stack'; + }; + results.push({ override_success: true }); + + // Trigger stack trace + try { + throw new Error('trigger stack trace'); + } catch (e) { + void e.stack; + } + } catch (e) { + results.push({ override_blocked: true, error: e.name }); + } + + return results; + `; + + const result = + await enclave.run< + Array<{ override_blocked?: boolean; override_success?: boolean; customPST_called?: boolean }> + >(code); + + if (result.success && Array.isArray(result.value)) { + // Override should be blocked or prepareStackTrace should not expose CallSite functions + const overrideResult = result.value.find((r) => r.override_blocked === true); + const callSiteBlocked = result.value.find((r) => 'callSiteBlocked' in r); + + // Either override should be blocked, or CallSite access should be blocked + expect(overrideResult || callSiteBlocked).toBeTruthy(); + } + + enclave.dispose(); + }, 15000); + + it('ATK-STACK-02: should not expose CallSite.getFunction() to sandbox', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Try to access CallSite objects via various methods + try { + const err = new Error('test'); + const stack = err.stack; + results.push({ stackType: typeof stack, stackLength: stack?.length }); + + // Stack should be redacted or not contain exploitable information + if (typeof stack === 'string') { + // Check if stack contains host file paths + const hasHostPath = stack.includes('/Users/') || stack.includes('\\\\Users\\\\') || + stack.includes('/home/') || stack.includes('node_modules'); + results.push({ hasHostPath }); + } + } catch (e) { + results.push({ stackAccess: 'blocked', error: e.name }); + } + + return results; + `; + + const result = await enclave.run<Array<{ hasHostPath?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + // Stack traces should not contain host paths when sanitization is enabled + const pathResult = result.value.find((r) => 'hasHostPath' in r); + if (pathResult) { + expect(pathResult.hasHostPath).toBe(false); + } + } + + enclave.dispose(); + }, 15000); + + it('ATK-STACK-03: should redact all host stack frames', async () => { + const enclave = new Enclave({ securityLevel: 'STRICT', timeout: 5000 }); + const code = ` + try { + throw new Error('test error'); + } catch (e) { + return { + message: e.message, + stack: e.stack, + hasStack: !!e.stack + }; + } + `; + + const result = await enclave.run<{ message: string; stack?: string; hasStack: boolean }>(code); + + if (result.success && result.value) { + // Stack should be redacted in STRICT mode + if (result.value.stack) { + // Stack should not contain real file paths + expect(result.value.stack).not.toMatch(/\/[a-zA-Z]/); + expect(result.value.stack).not.toMatch(/[a-zA-Z]:\\/); + // Stack frames should be redacted + expect(result.value.stack).toMatch(/REDACTED/i); + } + } + + enclave.dispose(); + }, 15000); + + it('ATK-STACK-04: should lock Error.stackTraceLimit', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Try to modify Error.stackTraceLimit + const originalLimit = Error.stackTraceLimit; + results.push({ originalLimit }); + + try { + Error.stackTraceLimit = Infinity; + const newLimit = Error.stackTraceLimit; + results.push({ newLimit, modified: newLimit !== originalLimit }); + } catch (e) { + results.push({ modifyBlocked: true, error: e.name }); + } + + return results; + `; + + const result = + await enclave.run<Array<{ originalLimit?: number; modified?: boolean; modifyBlocked?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + // stackTraceLimit should be locked (not modifiable) + const modResult = result.value.find((r) => 'modified' in r || 'modifyBlocked' in r); + if (modResult) { + expect(modResult.modified !== true || modResult.modifyBlocked === true).toBe(true); + } + } + + enclave.dispose(); + }, 15000); + + it('ATK-STACK-05: should handle stack-trace origin ambiguity safely (SandDriller)', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Test that errors from different origins are handled safely + // SandDriller found issues with VM internal error ambiguity + + // Trigger error in nested function context + function outer() { + function inner() { + throw new Error('inner error'); + } + inner(); + } + + try { + outer(); + } catch (e) { + // Try to exploit the error + try { + const F = e.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_VIA_NESTED_ERROR"'); + results.push({ escaped: fn() === 'ESCAPED_VIA_NESTED_ERROR' }); + } else { + results.push({ blocked: true, reason: 'not_function' }); + } + } catch (err) { + results.push({ blocked: true, error: err.name }); + } + } + + return results; + `; + + const result = await enclave.run<Array<{ escaped?: boolean; blocked?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + for (const r of result.value) { + expect(r.escaped).not.toBe(true); + } + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-CTOR: Function Constructor Access Paths', () => { + /** + * Tests for all paths to Function constructor + * This is the primary escape vector for JavaScript sandbox escapes + */ + + it('ATK-CTOR-01: should block (async function(){}).constructor access', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + try { + const AsyncFunction = (async function(){}).constructor; + if (typeof AsyncFunction === 'function') { + const fn = AsyncFunction('return "ESCAPED_VIA_ASYNC_FUNCTION"'); + return { escaped: (await fn()) === 'ESCAPED_VIA_ASYNC_FUNCTION' }; + } + return { blocked: true, reason: 'not_function' }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run<{ escaped?: boolean; blocked?: boolean }>(code); + + // Should be blocked at validation (.constructor access) + expect(result.success).toBe(false); + enclave.dispose(); + }, 15000); + + it('ATK-CTOR-02: should block (function*(){}).constructor access', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + try { + const GeneratorFunction = (function*(){}).constructor; + if (typeof GeneratorFunction === 'function') { + const gen = GeneratorFunction('yield "ESCAPED_VIA_GENERATOR"')(); + return { escaped: gen.next().value === 'ESCAPED_VIA_GENERATOR' }; + } + return { blocked: true, reason: 'not_function' }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run<{ escaped?: boolean; blocked?: boolean }>(code); + + expect(result.success).toBe(false); + enclave.dispose(); + }, 15000); + + it('ATK-CTOR-03: should block (async function*(){}).constructor access', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + try { + const AsyncGeneratorFunction = (async function*(){}).constructor; + if (typeof AsyncGeneratorFunction === 'function') { + const gen = AsyncGeneratorFunction('yield "ESCAPED_VIA_ASYNC_GEN"')(); + const result = await gen.next(); + return { escaped: result.value === 'ESCAPED_VIA_ASYNC_GEN' }; + } + return { blocked: true, reason: 'not_function' }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run<{ escaped?: boolean; blocked?: boolean }>(code); + + expect(result.success).toBe(false); + enclave.dispose(); + }, 15000); + + it('ATK-CTOR-04: should report violations on any constructor access attempt', async () => { + // This test verifies that constructor access attempts are blocked at validation + const enclave = new Enclave({ timeout: 5000 }); + + const testCases = [ + { code: `({}).constructor`, description: 'object constructor' }, + { code: `[].constructor`, description: 'array constructor' }, + { code: `"".constructor`, description: 'string constructor' }, + { code: `(42).constructor`, description: 'number constructor' }, + { code: `(() => {}).constructor`, description: 'arrow function constructor' }, + ]; + + for (const tc of testCases) { + const result = await enclave.run(`return ${tc.code};`); + // All should fail validation + expect(result.success).toBe(false); + } + + enclave.dispose(); + }, 15000); + + it('ATK-CTOR-05: should not allow code execution via any generator constructor', async () => { + const enclave = new Enclave({ timeout: 5000 }); + + // Test all generator types + const generatorTests = [ + `(function*(){}).constructor('yield process')`, + `(async function*(){}).constructor('yield process')`, + `Object.getPrototypeOf(function*(){}).constructor('yield process')`, + ]; + + for (const code of generatorTests) { + const result = await enclave.run(`return ${code};`); + // All should fail validation + expect(result.success).toBe(false); + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SYMBOL: Symbol API Security', () => { + /** + * Tests for Symbol API security + * Symbol.for() can create global symbols that might be used for escape + */ + + it('ATK-SYMBOL-01: should handle Symbol.for() safely', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Test Symbol.for availability + try { + const sym = Symbol.for('test-key'); + results.push({ test: 'symbol_for', type: typeof sym, isSymbol: typeof sym === 'symbol' }); + + // Test if same key returns same symbol (Symbol.for semantics) + const sym2 = Symbol.for('test-key'); + results.push({ test: 'same_key', sameSymbol: sym === sym2 }); + + } catch (e) { + results.push({ test: 'symbol_for', blocked: true, error: e.name }); + } + + // Test Symbol.keyFor + try { + const sym = Symbol.for('my-key'); + const key = Symbol.keyFor(sym); + results.push({ test: 'symbol_keyFor', key }); + } catch (e) { + results.push({ test: 'symbol_keyFor', blocked: true, error: e.name }); + } + + return results; + `; + + const result = await enclave.run<Array<{ test: string; blocked?: boolean }>>(code); + + // Document the behavior - Symbol.for may be available but should not enable escape + if (result.success && Array.isArray(result.value)) { + expect(result.value.length).toBeGreaterThan(0); + } + + enclave.dispose(); + }, 15000); + + it('ATK-SYMBOL-02: should prevent well-known symbol property manipulation', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Try to modify well-known symbols on built-ins + const wellKnownSymbols = [ + { obj: Array.prototype, symbol: Symbol.iterator, name: 'Array[Symbol.iterator]' }, + { obj: String.prototype, symbol: Symbol.iterator, name: 'String[Symbol.iterator]' }, + { obj: Promise, symbol: Symbol.species, name: 'Promise[Symbol.species]' }, + ]; + + for (const { obj, symbol, name } of wellKnownSymbols) { + try { + const original = obj[symbol]; + obj[symbol] = function() { return 'hijacked'; }; + const modified = obj[symbol] !== original; + results.push({ name, modified }); + } catch (e) { + results.push({ name, blocked: true, error: e.name }); + } + } + + return results; + `; + + const result = await enclave.run<Array<{ name: string; modified?: boolean; blocked?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + // Well-known symbol modifications should be blocked (frozen prototypes) + for (const r of result.value) { + expect(r.modified).not.toBe(true); + } + } + + enclave.dispose(); + }, 15000); + + it('ATK-SYMBOL-03: should not allow Symbol-based hidden property attacks', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Try to use symbols to hide malicious properties + try { + const hiddenKey = Symbol('hidden'); + const obj = { + [hiddenKey]: function() { + // Try to escape via hidden function + try { + const F = arguments.callee.constructor; + return F('return "ESCAPED"')(); + } catch (e) { + return 'blocked: ' + e.name; + } + } + }; + + const result = obj[hiddenKey](); + results.push({ test: 'hidden_symbol_func', result, escaped: result === 'ESCAPED' }); + } catch (e) { + results.push({ test: 'hidden_symbol_func', blocked: true, error: e.name }); + } + + return results; + `; + + const result = await enclave.run<Array<{ test: string; escaped?: boolean; blocked?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + for (const r of result.value) { + expect(r.escaped).not.toBe(true); + } + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-IMPORT: Import Keyword Payloads (SandDriller)', () => { + /** + * SandDriller Research: Import keyword payloads for sandbox escape + * + * Attack Mechanism: + * - Dynamic import() can bypass sandbox + * - import.meta can expose host information + */ + + it('ATK-IMPORT-01: should block dynamic import() expressions', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const module = await import('fs'); + return module; + `; + + const result = await enclave.run(code); + + // import() should be blocked at validation + expect(result.success).toBe(false); + enclave.dispose(); + }, 15000); + + it('ATK-IMPORT-02: should block import.meta access', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + return import.meta.url; + `; + + const result = await enclave.run(code); + + // import.meta should be blocked at validation + expect(result.success).toBe(false); + enclave.dispose(); + }, 15000); + + it('ATK-IMPORT-03: should block all import-related escape vectors', async () => { + const enclave = new Enclave({ timeout: 5000 }); + + const importTests = [ + { code: `import('child_process')`, description: 'dynamic import child_process' }, + { code: `import('vm')`, description: 'dynamic import vm' }, + { code: `import.meta`, description: 'import.meta access' }, + ]; + + for (const test of importTests) { + const result = await enclave.run(`return ${test.code};`); + expect(result.success).toBe(false); + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-BIGINT: BigInt Resource Exhaustion', () => { + /** + * BigInt operations can cause CPU exhaustion + * Large exponentiation is particularly dangerous + */ + + it('ATK-BIGINT-01: should timeout on large BigInt exponentiation', async () => { + const enclave = new Enclave({ timeout: 1000 }); + const code = ` + // This should be blocked by AST validation or timeout + let x = 2n; + for (let i = 0; i < 100; i++) { + x = x ** 1000n; + } + return x.toString().length; + `; + + const result = await enclave.run(code); + + // Should fail - either validation blocks it or it times out + expect(result.success).toBe(false); + enclave.dispose(); + }, 15000); + + it('ATK-BIGINT-02: should handle BigInt memory exhaustion gracefully', async () => { + const enclave = new Enclave({ timeout: 2000 }); + const code = ` + // Large BigInt that would consume significant memory + try { + const huge = 10n ** 10000n; + return { success: true, digits: huge.toString().length }; + } catch (e) { + return { blocked: true, error: e.name, message: e.message }; + } + `; + + const result = await enclave.run<{ success?: boolean; blocked?: boolean; digits?: number }>(code); + + // Either succeeds with reasonable size or is blocked + if (result.success && result.value?.success) { + // If it succeeded, verify it didn't create unreasonably large numbers + expect(result.value.digits).toBeLessThan(100000); + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-CAUSE: Error.cause Chain Traversal', () => { + /** + * Error.cause can hold arbitrary objects + * Chain of errors could leak information or enable prototype chain traversal + */ + + it('ATK-CAUSE-01: should not allow prototype chain access via Error.cause', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Create nested error with cause chain + const innerError = new Error('inner'); + const outerError = new Error('outer', { cause: innerError }); + + // Try to escape via cause chain + try { + const cause = outerError.cause; + const F = cause.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_VIA_CAUSE"'); + results.push({ escaped: fn() === 'ESCAPED_VIA_CAUSE' }); + } else { + results.push({ blocked: true, reason: 'not_function' }); + } + } catch (e) { + results.push({ blocked: true, error: e.name }); + } + + return results; + `; + + const result = await enclave.run<Array<{ escaped?: boolean; blocked?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + for (const r of result.value) { + expect(r.escaped).not.toBe(true); + } + } + + enclave.dispose(); + }, 15000); + + it('ATK-CAUSE-02: should handle cause property safely on errors', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Test that Error.cause works but doesn't enable escape + const rootCause = new Error('root'); + const middleError = new Error('middle', { cause: rootCause }); + const outerError = new Error('outer', { cause: middleError }); + + // Verify cause chain exists + results.push({ hasCause: outerError.cause !== undefined }); + results.push({ causeHasCause: outerError.cause?.cause !== undefined }); + + // Verify escape is not possible at any level + const errors = [outerError, middleError, rootCause]; + for (let i = 0; i < errors.length; i++) { + try { + const F = errors[i].constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_LEVEL_' + i + '"'); + results.push({ level: i, escaped: fn().startsWith('ESCAPED') }); + } else { + results.push({ level: i, blocked: true }); + } + } catch (e) { + results.push({ level: i, blocked: true, error: e.name }); + } + } + + return results; + `; + + const result = await enclave.run<Array<{ level?: number; escaped?: boolean; blocked?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + for (const r of result.value) { + if ('escaped' in r) { + expect(r.escaped).not.toBe(true); + } + } + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-CALLEE: arguments.callee.caller Escape', () => { + /** + * In non-strict mode, arguments.callee.caller accesses calling function + * Can traverse call stack to find host-context functions + */ + + it('ATK-CALLEE-01: should block arguments.callee.caller in strict mode', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + function outer() { + function inner() { + try { + // In strict mode, arguments.callee should throw + const callee = arguments.callee; + results.push({ calleeAccess: true }); + + // Try to get caller + const caller = callee.caller; + results.push({ callerAccess: true, callerType: typeof caller }); + } catch (e) { + results.push({ blocked: true, error: e.name }); + } + } + inner(); + } + outer(); + + return results; + `; + + const result = + await enclave.run<Array<{ blocked?: boolean; calleeAccess?: boolean; callerAccess?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + // In strict mode, arguments.callee should throw TypeError + const blocked = result.value.find((r) => r.blocked === true); + expect(blocked).toBeTruthy(); + } + + enclave.dispose(); + }, 15000); + + it('ATK-CALLEE-02: should not allow caller traversal for escape', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + // Even if we could access caller, it should not enable escape + const results = []; + + try { + // This should fail in strict mode + function test() { + return test.caller; + } + const caller = test(); + results.push({ callerType: typeof caller }); + } catch (e) { + results.push({ blocked: true, error: e.name }); + } + + return results; + `; + + const result = await enclave.run<Array<{ blocked?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + // function.caller should be blocked in strict mode + const blocked = result.value.find((r) => r.blocked === true); + expect(blocked).toBeTruthy(); + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-HOST-SENTINEL: Host Code Execution Prevention', () => { + /** + * Critical test: Verify that no sandbox escape can execute arbitrary host code + * Uses a sentinel function injected into globals + */ + + it('ATK-HOST-SENTINEL-01: comprehensive sentinel test across all escape vectors', async () => { + let hostCodeExecuted = false; + + const enclave = new Enclave({ + timeout: 10000, + allowFunctionsInGlobals: true, + globals: { + __SENTINEL__: { + execute: () => { + hostCodeExecuted = true; + return 'HOST_CODE_EXECUTED'; + }, + }, + }, + }); + + const code = ` + const results = []; + + // Define escape attempt helper + function tryEscape(name, escapeFn) { + try { + const result = escapeFn(); + results.push({ name, result, success: true }); + } catch (e) { + results.push({ name, blocked: true, error: e.name }); + } + } + + // Vector 1: Stack overflow -> constructor.constructor + let stackError; + const overflow = () => { try { overflow(); } catch(e) { stackError = e; } }; + overflow(); + if (stackError) { + tryEscape('stack_overflow_ctor', () => { + const F = stackError.constructor.constructor; + if (typeof F === 'function') return F('return __SENTINEL__.execute()')(); + return 'not_function'; + }); + } + + // Vector 2: Error.constructor.constructor + tryEscape('error_ctor', () => { + const F = new Error().constructor.constructor; + if (typeof F === 'function') return F('return __SENTINEL__.execute()')(); + return 'not_function'; + }); + + // Vector 3: Arrow function constructor + tryEscape('arrow_ctor', () => { + const F = (() => {}).constructor; + if (typeof F === 'function') return F('return __SENTINEL__.execute()')(); + return 'not_function'; + }); + + // Vector 4: toString.constructor + tryEscape('toString_ctor', () => { + const F = Object.prototype.toString.constructor; + if (typeof F === 'function') return F('return __SENTINEL__.execute()')(); + return 'not_function'; + }); + + // Vector 5: Array method constructor + tryEscape('array_method_ctor', () => { + const F = [].map.constructor; + if (typeof F === 'function') return F('return __SENTINEL__.execute()')(); + return 'not_function'; + }); + + // Vector 6: Async function constructor + tryEscape('async_ctor', () => { + const F = (async () => {}).constructor; + if (typeof F === 'function') return F('return __SENTINEL__.execute()')(); + return 'not_function'; + }); + + // Vector 7: Promise then handler + tryEscape('promise_then', () => { + return new Promise((resolve) => { + Promise.resolve().then(function handler() { + try { + const F = handler.constructor; + if (typeof F === 'function') resolve(F('return __SENTINEL__.execute()')()); + } catch (e) { + resolve('blocked: ' + e.name); + } + }); + }); + }); + + return { + attempts: results, + totalAttempts: results.length + }; + `; + + const result = await enclave.run<{ + attempts: Array<{ name: string; result?: string; blocked?: boolean }>; + totalAttempts: number; + }>(code); + + // CRITICAL ASSERTION: Host code must NEVER have executed + expect(hostCodeExecuted).toBe(false); + + // If execution succeeded, verify no attempt triggered the sentinel + if (result.success && result.value) { + for (const attempt of result.value.attempts) { + expect(attempt.result).not.toBe('HOST_CODE_EXECUTED'); + } + } + + enclave.dispose(); + }, 30000); + }); +});
libs/core/src/__tests__/enclave.attack-matrix.spec.ts+365 −0 modified@@ -1339,4 +1339,369 @@ describe('Enclave Attack Matrix', () => { enclave.dispose(); }); }); + + describe('Stack Overflow Prototype Chain Escape (ATK-SOE)', () => { + /** + * Tests for CVE-2023-29017 style attacks where stack overflow errors + * are exploited to traverse prototype chains and escape the sandbox. + * + * IMPORTANT: Full test suite is in: enclave.stack-overflow-escape.spec.ts + * These tests here are simplified versions for the attack matrix overview. + * The dedicated file provides comprehensive coverage including edge cases. + */ + + it('ATK-SOE-HOST: should never execute host code via escape attempts (sentinel test)', async () => { + // CRITICAL: This is the definitive test - if hostCodeExecuted becomes true, + // the sandbox has been compromised + let hostCodeExecuted = false; + + const enclave = new Enclave({ + timeout: 5000, + allowFunctionsInGlobals: true, // Required to inject sentinel function + globals: { + __sentinel__: { + trigger: () => { + hostCodeExecuted = true; + return 'HOST_CODE_EXECUTED'; + }, + }, + }, + }); + + // Attempt multiple escape vectors that try to call the sentinel + const code = ` + const escapeAttempts = []; + + // Helper to try calling sentinel via various paths + function tryEscape(name, fn) { + try { + const result = fn(); + escapeAttempts.push({ name, result, success: true }); + } catch (e) { + escapeAttempts.push({ name, error: e.name, blocked: true }); + } + } + + // Attempt 1: Direct stack overflow -> constructor.constructor + let stackOverflowError; + const overflow = () => { + try { overflow(); } catch (e) { stackOverflowError = e; } + }; + overflow(); + + if (stackOverflowError) { + tryEscape('stack_overflow_ctor', () => { + const F = stackOverflowError.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return __sentinel__.trigger()'); + return fn(); + } + return 'not_function'; + }); + + tryEscape('stack_overflow_proto_chain', () => { + const proto = stackOverflowError.__proto__.__proto__.__proto__; + proto.escape = stackOverflowError.toString.constructor; + const fn = proto.escape('return __sentinel__.trigger()'); + return fn(); + }); + } + + // Attempt 2: Error.constructor.constructor + tryEscape('error_ctor', () => { + const F = new Error().constructor.constructor; + if (typeof F === 'function') { + const fn = F('return __sentinel__.trigger()'); + return fn(); + } + return 'not_function'; + }); + + // Attempt 3: Function arrow expression + tryEscape('arrow_ctor', () => { + const F = (() => {}).constructor; + if (typeof F === 'function') { + const fn = F('return __sentinel__.trigger()'); + return fn(); + } + return 'not_function'; + }); + + // Attempt 4: toString.constructor + tryEscape('toString_ctor', () => { + const F = Object.prototype.toString.constructor; + if (typeof F === 'function') { + const fn = F('return __sentinel__.trigger()'); + return fn(); + } + return 'not_function'; + }); + + // Attempt 5: Array method constructor + tryEscape('array_method_ctor', () => { + const F = [].map.constructor; + if (typeof F === 'function') { + const fn = F('return __sentinel__.trigger()'); + return fn(); + } + return 'not_function'; + }); + + return { + attempts: escapeAttempts, + totalAttempts: escapeAttempts.length + }; + `; + + try { + const result = await enclave.run<{ + attempts: Array<{ name: string; result?: string; error?: string; blocked?: boolean }>; + totalAttempts: number; + }>(code); + + // CRITICAL ASSERTION: Host code must NEVER have executed + expect(hostCodeExecuted).toBe(false); + + // If execution succeeded, verify no attempt actually triggered the sentinel + if (result.success && result.value) { + for (const attempt of result.value.attempts) { + expect(attempt.result).not.toBe('HOST_CODE_EXECUTED'); + } + } + } finally { + enclave.dispose(); + } + }, 20000); + + it('ATK-SOE-01: should block Object.prototype modification via stack overflow', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + let rootProt; + const a = () => { + try { + a(); + } catch (e) { + rootProt = e["__proto__"]["__proto__"]["__proto__"]; + } + }; + a(); + + try { + rootProt["polluted"] = "pwned"; + return "VULNERABLE"; + } catch (e) { + return "PROTECTED: " + e.name; + } + `; + + const result = await enclave.run(code); + + // ALWAYS verify - never skip assertions on failure + if (result.success) { + expect(result.value).not.toBe('VULNERABLE'); + expect(result.value).toMatch(/PROTECTED|TypeError/); + } else { + // Execution failed - verify it was due to security measures + expect(result.error).toBeDefined(); + expect( + result.error?.code === 'VALIDATION_ERROR' || + result.error?.message?.includes('blocked') || + result.error?.message?.includes('not allowed') || + result.error?.name === 'TypeError' || + result.error?.name === 'EvalError', + ).toBe(true); + } + + // Verify host is not polluted + const testObj: Record<string, unknown> = {}; + expect(testObj['polluted']).toBeUndefined(); + enclave.dispose(); + }, 15000); + + it('ATK-SOE-05: should not pollute host Object.prototype', async () => { + const hostProtoKeys = Object.keys(Object.prototype); + + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const a = () => { + try { a(); } catch (e) { + try { e.__proto__.__proto__.__proto__.hostPolluted = true; } catch (err) {} + } + }; + a(); + return 'done'; + `; + + await enclave.run(code); + + // Verify host is unchanged + expect(Object.keys(Object.prototype)).toEqual(hostProtoKeys); + expect((Object.prototype as Record<string, unknown>)['hostPolluted']).toBeUndefined(); + enclave.dispose(); + }, 15000); + + it('ATK-SOE-06: should block direct Function escape via e.constructor.constructor with sentinel', async () => { + // CRITICAL TEST: Set up a sentinel function in globals that should NEVER execute + let hostCodeExecuted = false; + + const enclave = new Enclave({ + timeout: 5000, + allowFunctionsInGlobals: true, + globals: { + __hostSentinel__: () => { + hostCodeExecuted = true; + return 'HOST_COMPROMISED'; + }, + }, + }); + + const code = ` + const results = []; + + // Attempt 1: Stack overflow -> constructor.constructor -> call sentinel + let stackError; + const overflow = () => { + try { overflow(); } catch (e) { stackError = e; } + }; + overflow(); + + if (stackError) { + try { + const F = stackError.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return __hostSentinel__()'); + const result = fn(); + results.push({ attempt: 'stack_overflow_ctor', escaped: result === 'HOST_COMPROMISED', result }); + } else { + results.push({ attempt: 'stack_overflow_ctor', blocked: true, reason: 'not_function' }); + } + } catch (e) { + results.push({ attempt: 'stack_overflow_ctor', blocked: true, error: e.name }); + } + } + + // Attempt 2: Arrow function -> constructor -> call sentinel + try { + const F = (() => {}).constructor; + if (typeof F === 'function') { + const fn = F('return __hostSentinel__()'); + const result = fn(); + results.push({ attempt: 'arrow_ctor', escaped: result === 'HOST_COMPROMISED', result }); + } else { + results.push({ attempt: 'arrow_ctor', blocked: true, reason: 'not_function' }); + } + } catch (e) { + results.push({ attempt: 'arrow_ctor', blocked: true, error: e.name }); + } + + // Attempt 3: Error -> toString.constructor -> call sentinel + try { + const err = new Error('test'); + const F = err.toString.constructor; + if (typeof F === 'function') { + const fn = F('return __hostSentinel__()'); + const result = fn(); + results.push({ attempt: 'toString_ctor', escaped: result === 'HOST_COMPROMISED', result }); + } else { + results.push({ attempt: 'toString_ctor', blocked: true, reason: 'not_function' }); + } + } catch (e) { + results.push({ attempt: 'toString_ctor', blocked: true, error: e.name }); + } + + return { results, attemptCount: results.length }; + `; + + const result = await enclave.run<{ + results: Array<{ attempt: string; escaped?: boolean; blocked?: boolean; error?: string; result?: string }>; + attemptCount: number; + }>(code); + + // CRITICAL ASSERTION: Host sentinel must NEVER have executed + expect(hostCodeExecuted).toBe(false); + + // ALWAYS verify - never skip assertions + if (result.success && result.value) { + // None of the attempts should have escaped + for (const attempt of result.value.results) { + expect(attempt.escaped).not.toBe(true); + expect(attempt.result).not.toBe('HOST_COMPROMISED'); + } + } else { + // Execution failed - verify it was due to security measures + expect(result.error).toBeDefined(); + expect( + result.error?.code === 'VALIDATION_ERROR' || + result.error?.message?.includes('blocked') || + result.error?.name === 'TypeError' || + result.error?.name === 'EvalError', + ).toBe(true); + } + + enclave.dispose(); + }, 15000); + + it('ATK-SOE-07: should verify __proto__ shadowing works on all error types', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Test each error type + const errorTypes = [ + { name: 'Error', create: () => new Error('test') }, + { name: 'TypeError', create: () => new TypeError('test') }, + { name: 'RangeError', create: () => new RangeError('test') }, + { name: 'SyntaxError', create: () => new SyntaxError('test') }, + { name: 'ReferenceError', create: () => new ReferenceError('test') }, + { name: 'URIError', create: () => new URIError('test') }, + { name: 'EvalError', create: () => new EvalError('test') }, + ]; + + for (const { name, create } of errorTypes) { + try { + const err = create(); + const proto = err.__proto__; + results.push({ + errorType: name, + protoIsNull: proto === null, + protoValue: String(proto) + }); + } catch (e) { + results.push({ + errorType: name, + blocked: true, + error: e.name + }); + } + } + + return results; + `; + + const result = await enclave.run< + Array<{ + errorType: string; + protoIsNull?: boolean; + protoValue?: string; + blocked?: boolean; + }> + >(code); + + // ALWAYS verify + if (result.success && Array.isArray(result.value)) { + // All error types should have __proto__ returning null + for (const r of result.value) { + if (!r.blocked) { + expect(r.protoIsNull).toBe(true); + expect(r.protoValue).toBe('null'); + } + } + } else { + // Security failure is acceptable + expect(result.error).toBeDefined(); + } + + enclave.dispose(); + }, 15000); + }); });
libs/core/src/__tests__/enclave.stack-overflow-escape.spec.ts+1573 −0 added@@ -0,0 +1,1573 @@ +/** + * Stack Overflow Prototype Chain Escape Tests (CVE-2023-29017 style) + * + * Tests for the critical sandbox escape vulnerability where stack overflow errors + * can be exploited to access the host Function constructor via prototype chain traversal. + * + * Attack Pattern: + * 1. Trigger stack overflow via recursion + * 2. Catch the RangeError generated by V8 internals + * 3. Traverse prototype chain: error.__proto__.__proto__.__proto__ → Object.prototype + * 4. Add property to Object.prototype pointing to Function constructor + * 5. Execute arbitrary code via the Function constructor + * + * Reference: CVE-2023-29017 (vm2 sandbox escape) + */ + +import { Enclave } from '../enclave'; + +describe('Stack Overflow Prototype Chain Escape (ATK-SOE)', () => { + describe('ATK-SOE-01: Verify prototypes are frozen on stack overflow RangeError', () => { + it('should prevent Object.prototype modification via stack overflow error', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + let rootProt; + const a = () => { + try { + a(); // Recurse until stack overflow + } catch (e) { + // Try to get Object.prototype via prototype chain + rootProt = e["__proto__"]["__proto__"]["__proto__"]; + } + }; + a(); + + // Attempt to add a property to Object.prototype + try { + rootProt["polluted"] = "pwned"; + return "VULNERABLE: property added"; + } catch (modifyErr) { + return "PROTECTED: " + modifyErr.name; + } + `; + + const result = await enclave.run(code); + + // Should either fail validation, throw TypeError (frozen), or block __proto__ access + // ALWAYS verify - never skip on failure + if (result.success) { + expect(result.value).toMatch(/PROTECTED|TypeError/); + } else { + // If execution failed, verify it was due to security measures, not a bypass + expect(result.error).toBeDefined(); + // Acceptable failure reasons: validation blocked dangerous code, or runtime security error + expect( + result.error?.code === 'VALIDATION_ERROR' || + result.error?.message?.includes('blocked') || + result.error?.message?.includes('not allowed') || + result.error?.name === 'TypeError' || + result.error?.name === 'ReferenceError', + ).toBe(true); + } + + // Verify host Object.prototype is NOT polluted + const testObj: Record<string, unknown> = {}; + expect(testObj['polluted']).toBeUndefined(); + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SOE-02: Block property addition to Object.prototype via stack overflow', () => { + it('should throw TypeError when trying to add property to frozen prototype', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + let proto; + function recurse() { + try { + recurse(); + } catch (e) { + proto = Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(e))); + } + } + recurse(); + + // Attempt to modify the prototype + try { + proto.maliciousProp = 'evil'; + return { success: true, frozen: false }; + } catch (e) { + return { success: false, frozen: true, errorType: e.name }; + } + `; + + const result = await enclave.run<{ success: boolean; frozen: boolean; errorType?: string }>(code); + + // ALWAYS verify - never skip assertions on failure + if (result.success && result.value) { + // If we got a result, the prototype should be frozen + expect(result.value.frozen).toBe(true); + expect(result.value.errorType).toBe('TypeError'); + } else { + // Execution failed - verify it was due to security measures, not a bypass + expect(result.error).toBeDefined(); + expect( + result.error?.code === 'VALIDATION_ERROR' || + result.error?.message?.includes('blocked') || + result.error?.message?.includes('not allowed') || + result.error?.name === 'TypeError' || + result.error?.name === 'EvalError', + ).toBe(true); + } + + // Double-check host is not polluted + expect(Object.prototype.hasOwnProperty('maliciousProp')).toBe(false); + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SOE-03: Full CVE-2023-29017-style attack is blocked', () => { + it('should block the complete exploit chain', async () => { + const enclave = new Enclave({ timeout: 5000 }); + // This is a simplified version of the CVE-2023-29017 attack + const code = ` + let rootProt; + let a = () => { + try { + a(); + } catch (e) { + rootProt = e[["__proto__"]][["__proto__"]][["__proto__"]]; + } + }; + a(); + + // Try to get Function constructor via toString.constructor + try { + rootProt[["foo"]] = rootProt[["toString"]][["constructor"]]; + const fn = rootProt[["foo"]]("return 'ESCAPED'"); + return fn(); + } catch (e) { + return "BLOCKED: " + e.name + " - " + e.message; + } + `; + + const result = await enclave.run(code); + + // The attack should be blocked at one of several points: + // 1. __proto__ access blocked by validation or secure proxy + // 2. Prototype frozen, can't add foo property + // 3. toString.constructor returns undefined or blocked + // 4. Function constructor throws EvalError due to codeGeneration.strings=false + // ALWAYS verify - never skip on failure + if (result.success) { + expect(result.value).not.toBe('ESCAPED'); + expect(result.value).toMatch(/BLOCKED|TypeError|EvalError|ReferenceError/); + } else { + // Execution failed - verify it was due to security measures + expect(result.error).toBeDefined(); + // Document: validation or runtime security blocked the attack + expect( + result.error?.code === 'VALIDATION_ERROR' || + result.error?.message?.includes('blocked') || + result.error?.message?.includes('not allowed') || + result.error?.name === 'TypeError' || + result.error?.name === 'EvalError', + ).toBe(true); + } + + enclave.dispose(); + }, 15000); + + /** + * SECURITY FIX IMPLEMENTED: This test validates the fix for String() coercion bypass. + * + * The attack uses String(['constructor']) which converts the array to 'constructor' string. + * This is now blocked at AST validation by detecting CallExpression patterns that could + * produce dangerous property names (constructor, __proto__, prototype). + * + * Defense: AST rule detects String(['constructor']), String.fromCharCode(...), + * ['constructor'].toString(), and ['constructor'].join('') patterns. + */ + it('should block attack using array coercion notation', async () => { + // Create completely fresh enclave for isolation + const enclave = new Enclave({ timeout: 5000 }); + // Array bracket notation with string coercion: [["prop"]] coerces to "prop" + // Updated test: even if typeof C === "function", verify it CANNOT execute arbitrary code + const code = ` + let rootProt; + const a = () => { + try { + a(); + } catch (e) { + // Use array notation which coerces to string + rootProt = e[String(['__proto__'])][String(['__proto__'])][String(['__proto__'])]; + } + }; + a(); + + try { + const toStr = rootProt[String(['toString'])]; + const C = toStr[String(['constructor'])]; + + // CRITICAL: Even if we got a function, verify it cannot be used to escape + if (typeof C === 'function') { + try { + // Attempt to use it for code execution + const fn = C('return "ESCAPED_VIA_ARRAY_COERCION"'); + const result = fn(); + if (result === 'ESCAPED_VIA_ARRAY_COERCION') { + return { escaped: true, method: 'array_coercion' }; + } + return { blocked: true, reason: 'fn_returned_unexpected', result }; + } catch (execErr) { + // Function exists but cannot be used - this is acceptable + return { blocked: true, reason: 'execution_blocked', error: execErr.name }; + } + } + return { blocked: true, reason: 'not_function', type: typeof C }; + } catch (e) { + return { blocked: true, reason: 'access_blocked', error: e.name }; + } + `; + + const result = await enclave.run<{ + escaped?: boolean; + blocked?: boolean; + reason?: string; + error?: string; + }>(code); + + // ALWAYS verify - never skip on failure + if (result.success && result.value) { + // CRITICAL: escape must NOT have succeeded + expect(result.value.escaped).not.toBe(true); + // If blocked, verify blocking mechanism is security-related + if (result.value.blocked && result.value.error) { + expect(['EvalError', 'TypeError', 'ReferenceError']).toContain(result.value.error); + } + } else { + // Execution failed - verify it was due to security measures + expect(result.error).toBeDefined(); + expect( + result.error?.code === 'VALIDATION_ERROR' || + result.error?.message?.includes('blocked') || + result.error?.message?.includes('not allowed'), + ).toBe(true); + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SOE-04: Function constructor inaccessible via error prototype chain', () => { + it('should not provide a working path to Function constructor from error', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Trigger stack overflow and capture the error + let caughtError; + const overflow = () => { + try { + overflow(); + } catch (e) { + caughtError = e; + } + }; + overflow(); + + // Try various paths to Function constructor + const paths = [ + () => caughtError.constructor.constructor, + () => caughtError.__proto__.constructor.constructor, + () => Object.getPrototypeOf(caughtError).constructor.constructor, + () => caughtError.toString.constructor, + () => (() => {}).constructor, + ]; + + for (const pathFn of paths) { + try { + const F = pathFn(); + if (F && typeof F === 'function') { + // Try to use it to create arbitrary code + try { + const fn = F('return "escaped"'); + const fnResult = fn(); + results.push({ path: pathFn.toString().substring(0, 50), escaped: fnResult === 'escaped' }); + } catch (e) { + results.push({ path: pathFn.toString().substring(0, 50), blocked: true, error: e.name }); + } + } else { + results.push({ path: pathFn.toString().substring(0, 50), notFunction: true }); + } + } catch (e) { + results.push({ path: pathFn.toString().substring(0, 50), accessBlocked: true, error: e.name }); + } + } + + return results; + `; + + const result = await enclave.run(code); + + // ALWAYS verify - never skip assertions on failure + if (result.success && Array.isArray(result.value)) { + // None of the paths should lead to successful escape + for (const pathResult of result.value as Array<{ escaped?: boolean }>) { + expect(pathResult.escaped).not.toBe(true); + } + } else { + // Execution failed - verify it was due to security measures, not a bypass + expect(result.error).toBeDefined(); + expect( + result.error?.code === 'VALIDATION_ERROR' || + result.error?.message?.includes('blocked') || + result.error?.message?.includes('not allowed') || + result.error?.name === 'TypeError' || + result.error?.name === 'EvalError', + ).toBe(true); + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SOE-05: Stack overflow error does not pollute host Object.prototype', () => { + it('should keep host realm prototypes completely unchanged', async () => { + // Capture host prototype state before + const hostProtoKeys = Object.keys(Object.prototype); + const hostArrayProtoKeys = Object.keys(Array.prototype); + + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + // Attempt aggressive prototype pollution via stack overflow + const a = () => { + try { + a(); + } catch (e) { + // Try all possible ways to pollute + try { e.__proto__.hostPolluted = true; } catch (err) {} + try { e.__proto__.__proto__.hostPolluted = true; } catch (err) {} + try { e.__proto__.__proto__.__proto__.hostPolluted = true; } catch (err) {} + try { Object.prototype.hostPolluted = true; } catch (err) {} + try { Array.prototype.hostPolluted = true; } catch (err) {} + } + }; + a(); + return 'attempted'; + `; + + await enclave.run(code); + + // Verify host prototypes are unchanged + expect(Object.keys(Object.prototype)).toEqual(hostProtoKeys); + expect(Object.keys(Array.prototype)).toEqual(hostArrayProtoKeys); + expect((Object.prototype as Record<string, unknown>)['hostPolluted']).toBeUndefined(); + expect((Array.prototype as unknown as Record<string, unknown>)['hostPolluted']).toBeUndefined(); + + // Verify by creating new objects + const newObj: Record<string, unknown> = {}; + expect(newObj['hostPolluted']).toBeUndefined(); + + enclave.dispose(); + }, 15000); + + it('should isolate sandbox prototype changes from host across multiple runs', async () => { + const enclave1 = new Enclave({ timeout: 5000 }); + const enclave2 = new Enclave({ timeout: 5000 }); + + // First enclave attempts pollution + const code1 = ` + const a = () => { + try { a(); } catch (e) { + try { e.__proto__.__proto__.__proto__.crossEnclave = 'from1'; } catch (err) {} + } + }; + a(); + return 'done1'; + `; + + await enclave1.run(code1); + + // Second enclave checks if it can see the pollution + const code2 = ` + const obj = {}; + return { + hasCrossEnclave: 'crossEnclave' in obj, + value: obj.crossEnclave + }; + `; + + const result2 = await enclave2.run<{ hasCrossEnclave: boolean; value: unknown }>(code2); + + if (result2.success && result2.value) { + expect(result2.value.hasCrossEnclave).toBe(false); + expect(result2.value.value).toBeUndefined(); + } + + // Host should also not see it + const testObj: Record<string, unknown> = {}; + expect(testObj['crossEnclave']).toBeUndefined(); + + enclave1.dispose(); + enclave2.dispose(); + }, 20000); + }); + + describe('ATK-SOE-06: Block array coercion bracket notation', () => { + it('should treat [["__proto__"]] the same as ["__proto__"]', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const obj = {}; + const results = []; + + // Test that array coercion doesn't bypass protections + try { + // This should behave the same as obj["__proto__"] + const proto1 = obj[["__proto__"]]; + results.push({ method: 'array_coercion', accessed: true, type: typeof proto1 }); + } catch (e) { + results.push({ method: 'array_coercion', blocked: true, error: e.name }); + } + + try { + // Direct access for comparison + const proto2 = obj["__proto__"]; + results.push({ method: 'direct', accessed: true, type: typeof proto2 }); + } catch (e) { + results.push({ method: 'direct', blocked: true, error: e.name }); + } + + // Try to use array coercion to modify prototype + try { + obj[["__proto__"]][["testProp"]] = "test"; + results.push({ method: 'proto_modify', succeeded: true }); + } catch (e) { + results.push({ method: 'proto_modify', blocked: true, error: e.name }); + } + + return results; + `; + + const result = await enclave.run(code); + + if (result.success && Array.isArray(result.value)) { + // Find the proto_modify result + const modifyResult = (result.value as Array<{ method: string; blocked?: boolean }>).find( + (r) => r.method === 'proto_modify', + ); + // Modification should be blocked (TypeError due to frozen prototype) + if (modifyResult) { + expect(modifyResult.blocked).toBe(true); + } + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SOE-07: Block toString coercion for computed property access', () => { + it('should not allow malicious toString to bypass protections', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Create object with malicious toString + const maliciousKey = { + toString: function() { + return '__proto__'; + } + }; + + const obj = {}; + + // Try to access __proto__ via toString coercion + try { + const proto = obj[maliciousKey]; + results.push({ test: 'access_via_toString', accessed: true, type: typeof proto }); + } catch (e) { + results.push({ test: 'access_via_toString', blocked: true, error: e.name }); + } + + // Try to modify via toString coercion + try { + obj[maliciousKey].toStringCoerced = true; + results.push({ test: 'modify_via_toString', succeeded: true }); + } catch (e) { + results.push({ test: 'modify_via_toString', blocked: true, error: e.name }); + } + + // Try with constructor + const constructorKey = { + toString: () => 'constructor' + }; + + try { + const ctor = obj[constructorKey]; + results.push({ test: 'constructor_via_toString', accessed: true, type: typeof ctor }); + } catch (e) { + results.push({ test: 'constructor_via_toString', blocked: true, error: e.name }); + } + + return results; + `; + + const result = await enclave.run(code); + + if (result.success && Array.isArray(result.value)) { + // Modification via toString coercion should be blocked + const modifyResult = (result.value as Array<{ test: string; blocked?: boolean }>).find( + (r) => r.test === 'modify_via_toString', + ); + if (modifyResult) { + expect(modifyResult.blocked).toBe(true); + } + } + + enclave.dispose(); + }, 15000); + + it('should handle Symbol.toPrimitive attempts', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Create object with Symbol.toPrimitive + const sneakyKey = { + [Symbol.toPrimitive]: function(hint) { + return '__proto__'; + } + }; + + const obj = {}; + + try { + const proto = obj[sneakyKey]; + results.push({ test: 'toPrimitive', accessed: true, type: typeof proto }); + } catch (e) { + results.push({ test: 'toPrimitive', blocked: true, error: e.name }); + } + + // Try to modify + try { + obj[sneakyKey].primitiveTest = true; + results.push({ test: 'toPrimitive_modify', succeeded: true }); + } catch (e) { + results.push({ test: 'toPrimitive_modify', blocked: true, error: e.name }); + } + + return results; + `; + + const result = await enclave.run(code); + + if (result.success && Array.isArray(result.value)) { + const modifyResult = (result.value as Array<{ test: string; blocked?: boolean }>).find( + (r) => r.test === 'toPrimitive_modify', + ); + if (modifyResult) { + expect(modifyResult.blocked).toBe(true); + } + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SOE-08: Verify error constructor chain is secure', () => { + it('should not expose working Function via error constructor chain', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Create various error types and test their constructor chains + const errorTypes = [ + new Error('test'), + new TypeError('test'), + new RangeError('test'), + new SyntaxError('test'), + new ReferenceError('test'), + new URIError('test'), + new EvalError('test'), + ]; + + for (const err of errorTypes) { + const errName = err.constructor.name; + + // Try to get Function via constructor.constructor + try { + const F = err.constructor.constructor; + if (typeof F === 'function') { + try { + const fn = F('return "escaped_" + errName'); + const result = fn(); + results.push({ error: errName, path: 'constructor.constructor', escaped: true, result }); + } catch (e) { + results.push({ error: errName, path: 'constructor.constructor', blocked: e.name }); + } + } else { + results.push({ error: errName, path: 'constructor.constructor', notFunction: true }); + } + } catch (e) { + results.push({ error: errName, path: 'constructor.constructor', accessBlocked: e.name }); + } + } + + return results; + `; + + const result = await enclave.run(code); + + if (result.success && Array.isArray(result.value)) { + // None should have escaped + for (const r of result.value as Array<{ escaped?: boolean }>) { + expect(r.escaped).not.toBe(true); + } + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SOE-09: Async stack overflow handling', () => { + it('should handle async recursion stack overflow securely', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + let rootProt; + + async function asyncOverflow() { + try { + await asyncOverflow(); + } catch (e) { + rootProt = e.__proto__.__proto__.__proto__; + } + } + + try { + await asyncOverflow(); + } catch (e) { + // Expected to fail with stack overflow + } + + // Try to pollute via captured prototype + try { + if (rootProt) { + rootProt.asyncPolluted = true; + } + return { polluted: rootProt?.asyncPolluted === true }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run(code); + + if (result.success && result.value) { + const value = result.value as { polluted?: boolean; blocked?: boolean }; + expect(value.polluted).not.toBe(true); + } + + // Verify host is safe + expect((Object.prototype as Record<string, unknown>)['asyncPolluted']).toBeUndefined(); + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SOE-10: Generator function stack overflow', () => { + it('should handle generator stack overflow securely', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + let capturedProto; + + function* genOverflow() { + try { + yield* genOverflow(); + } catch (e) { + capturedProto = e.__proto__.__proto__.__proto__; + } + } + + try { + const gen = genOverflow(); + // Exhaust the generator to trigger stack overflow + while (true) { + const result = gen.next(); + if (result.done) break; + } + } catch (e) { + // Expected + } + + // Try to use captured prototype + try { + if (capturedProto) { + capturedProto.genPolluted = true; + } + return { success: false, polluted: capturedProto?.genPolluted === true }; + } catch (e) { + return { success: true, blocked: e.name }; + } + `; + + const result = await enclave.run(code); + + if (result.success && result.value) { + const value = result.value as { polluted?: boolean }; + expect(value.polluted).not.toBe(true); + } + + // Verify host is safe + expect((Object.prototype as Record<string, unknown>)['genPolluted']).toBeUndefined(); + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SOE-11: Object.getPrototypeOf prototype chain traversal', () => { + it('should block prototype modification via Object.getPrototypeOf traversal', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + let rootProt; + const a = () => { + try { + a(); + } catch (e) { + // Try to use Object.getPrototypeOf instead of __proto__ + rootProt = Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(e))); + } + }; + a(); + + // Even if we can traverse to Object.prototype, it should be frozen + try { + if (rootProt) { + rootProt.getProtoPolluted = true; + } + return { polluted: rootProt?.getProtoPolluted === true }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run(code); + + if (result.success && result.value) { + const value = result.value as { polluted?: boolean; blocked?: boolean }; + // Either blocked by TypeError (frozen) or polluted is false + expect(value.polluted).not.toBe(true); + } + + // Verify host is safe + expect((Object.prototype as Record<string, unknown>)['getProtoPolluted']).toBeUndefined(); + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SOE-12: Legacy prototype methods', () => { + it('should block __lookupGetter__ and __lookupSetter__', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Try __lookupGetter__ + try { + const getter = Object.prototype.__lookupGetter__('constructor'); + results.push({ method: '__lookupGetter__', result: typeof getter }); + } catch (e) { + results.push({ method: '__lookupGetter__', blocked: true, error: e.name }); + } + + // Try __lookupSetter__ + try { + const setter = Object.prototype.__lookupSetter__('constructor'); + results.push({ method: '__lookupSetter__', result: typeof setter }); + } catch (e) { + results.push({ method: '__lookupSetter__', blocked: true, error: e.name }); + } + + // Try __defineGetter__ + try { + const obj = {}; + obj.__defineGetter__('evil', () => 'pwned'); + results.push({ method: '__defineGetter__', success: obj.evil === 'pwned' }); + } catch (e) { + results.push({ method: '__defineGetter__', blocked: true, error: e.name }); + } + + // Try __defineSetter__ + try { + const obj = {}; + let captured = null; + obj.__defineSetter__('evil', (v) => { captured = v; }); + obj.evil = 'test'; + results.push({ method: '__defineSetter__', success: captured === 'test' }); + } catch (e) { + results.push({ method: '__defineSetter__', blocked: true, error: e.name }); + } + + return results; + `; + + const result = + await enclave.run<Array<{ method: string; result?: string; blocked?: boolean; success?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + // All legacy methods should either return undefined or be blocked + for (const r of result.value) { + // The methods should either return undefined or not work as expected + if (r.result) { + expect(r.result).toBe('undefined'); + } + if (r.success !== undefined) { + expect(r.success).toBe(false); + } + } + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SOE-13: Reflect.getPrototypeOf bypass attempt', () => { + it('should block prototype modification via Reflect.getPrototypeOf traversal', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + let rootProt; + const a = () => { + try { + a(); + } catch (e) { + // Try to use Reflect.getPrototypeOf (may not be available in STRICT mode) + try { + if (typeof Reflect !== 'undefined' && Reflect.getPrototypeOf) { + rootProt = Reflect.getPrototypeOf(Reflect.getPrototypeOf(Reflect.getPrototypeOf(e))); + } + } catch (reflectErr) { + // Reflect might not be available + rootProt = null; + } + } + }; + a(); + + // Even if we can traverse, Object.prototype should be frozen + try { + if (rootProt) { + rootProt.reflectPolluted = true; + } + return { polluted: rootProt?.reflectPolluted === true }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run(code); + + if (result.success && result.value) { + const value = result.value as { polluted?: boolean; blocked?: boolean }; + // Either blocked by TypeError (frozen) or polluted is false + expect(value.polluted).not.toBe(true); + } + + // Verify host is safe + expect((Object.prototype as Record<string, unknown>)['reflectPolluted']).toBeUndefined(); + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SOE-14: constructor.prototype manipulation', () => { + it('should block access to Function via error.constructor.prototype', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + let caughtError; + const a = () => { + try { + a(); + } catch (e) { + caughtError = e; + } + }; + a(); + + // Try to access Function constructor via constructor.prototype chain + const results = []; + + try { + const errorCtor = caughtError.constructor; + const errorProto = errorCtor.prototype; + const errorCtorCtor = errorCtor.constructor; // Should be Function + results.push({ + path: 'error.constructor.constructor', + type: typeof errorCtorCtor, + }); + + // Try to use it + if (typeof errorCtorCtor === 'function') { + try { + const fn = errorCtorCtor('return "ESCAPED"'); + results.push({ escaped: fn() === 'ESCAPED' }); + } catch (e) { + results.push({ blocked: true, error: e.name }); + } + } + } catch (e) { + results.push({ accessBlocked: true, error: e.name }); + } + + return results; + `; + + const result = await enclave.run<Array<{ escaped?: boolean; blocked?: boolean }>>(code); + + if (result.success && Array.isArray(result.value)) { + // None should have escaped + for (const r of result.value) { + expect(r.escaped).not.toBe(true); + } + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SOE-15: Constructor Chain Verification with Explicit Sentinel', () => { + it('should block escape via error.constructor.constructor with sentinel check', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + // Trigger stack overflow and capture error + let caughtError; + const overflow = () => { + try { + overflow(); + } catch (e) { + caughtError = e; + } + }; + overflow(); + + // Verify error was captured + if (!caughtError) { + return { blocked: true, reason: 'no_error_captured' }; + } + + // Attempt to get Function constructor via error.constructor.constructor + try { + const ErrorCtor = caughtError.constructor; + if (typeof ErrorCtor !== 'function') { + return { blocked: true, reason: 'error_constructor_not_function' }; + } + + const F = ErrorCtor.constructor; + if (typeof F !== 'function') { + return { blocked: true, reason: 'constructor_constructor_not_function' }; + } + + // CRITICAL: Attempt to execute arbitrary code as sentinel + // This is the exact CVE-2023-29017 escape pattern + try { + const fn = F('return "SENTINEL_ESCAPED"'); + const result = fn(); + if (result === 'SENTINEL_ESCAPED') { + return { escaped: true, sentinel: 'SENTINEL_ESCAPED' }; + } + return { blocked: true, reason: 'fn_returned_unexpected', result }; + } catch (execErr) { + return { blocked: true, reason: 'fn_execution_blocked', error: execErr.name }; + } + } catch (accessErr) { + return { blocked: true, reason: 'constructor_access_blocked', error: accessErr.name }; + } + `; + + const result = await enclave.run<{ + escaped?: boolean; + blocked?: boolean; + reason?: string; + error?: string; + sentinel?: string; + }>(code); + + // ALWAYS verify - never skip + if (result.success && result.value) { + // CRITICAL: sentinel must NOT have escaped + expect(result.value.escaped).not.toBe(true); + expect(result.value.sentinel).not.toBe('SENTINEL_ESCAPED'); + // If blocked, document the blocking mechanism + if (result.value.blocked) { + expect(['EvalError', 'TypeError', 'ReferenceError']).toContain(result.value.error ?? result.value.reason); + } + } else { + // Execution failed at validation/runtime - this is acceptable + expect(result.error).toBeDefined(); + } + + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SOE-16: Alternate Constructor Acquisition Paths', () => { + it('should block escape via error.toString.constructor', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + let caughtError; + const overflow = () => { + try { overflow(); } catch (e) { caughtError = e; } + }; + overflow(); + + try { + const toStr = caughtError.toString; + if (typeof toStr !== 'function') { + return { blocked: true, reason: 'toString_not_function' }; + } + const F = toStr.constructor; + if (typeof F !== 'function') { + return { blocked: true, reason: 'toString_constructor_not_function' }; + } + const fn = F('return "ESCAPED_VIA_TOSTRING"'); + return { escaped: fn() === 'ESCAPED_VIA_TOSTRING' }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run<{ escaped?: boolean; blocked?: boolean; error?: string }>(code); + + if (result.success && result.value) { + expect(result.value.escaped).not.toBe(true); + } + enclave.dispose(); + }, 15000); + + it('should block escape via error.valueOf.constructor', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + let caughtError; + const overflow = () => { + try { overflow(); } catch (e) { caughtError = e; } + }; + overflow(); + + try { + const valueOf = caughtError.valueOf; + if (typeof valueOf !== 'function') { + return { blocked: true, reason: 'valueOf_not_function' }; + } + const F = valueOf.constructor; + if (typeof F !== 'function') { + return { blocked: true, reason: 'valueOf_constructor_not_function' }; + } + const fn = F('return "ESCAPED_VIA_VALUEOF"'); + return { escaped: fn() === 'ESCAPED_VIA_VALUEOF' }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run<{ escaped?: boolean; blocked?: boolean; error?: string }>(code); + + if (result.success && result.value) { + expect(result.value.escaped).not.toBe(true); + } + enclave.dispose(); + }, 15000); + + it('should block escape via AsyncFunction constructor', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + try { + // Get AsyncFunction via Object.getPrototypeOf on async function + const asyncFn = async () => {}; + const AsyncFunction = Object.getPrototypeOf(asyncFn).constructor; + if (typeof AsyncFunction !== 'function') { + return { blocked: true, reason: 'AsyncFunction_not_function' }; + } + const fn = AsyncFunction('return "ESCAPED_VIA_ASYNC"'); + const result = await fn(); + return { escaped: result === 'ESCAPED_VIA_ASYNC' }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run<{ escaped?: boolean; blocked?: boolean; error?: string }>(code); + + // Should be blocked by validation (accessing .constructor is blocked) + if (result.success && result.value) { + expect(result.value.escaped).not.toBe(true); + } + enclave.dispose(); + }, 15000); + + it('should block escape via GeneratorFunction constructor', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + try { + // Get GeneratorFunction via Object.getPrototypeOf on generator + const genFn = function* () {}; + const GeneratorFunction = Object.getPrototypeOf(genFn).constructor; + if (typeof GeneratorFunction !== 'function') { + return { blocked: true, reason: 'GeneratorFunction_not_function' }; + } + const fn = GeneratorFunction('yield "ESCAPED_VIA_GEN"'); + const gen = fn(); + const result = gen.next().value; + return { escaped: result === 'ESCAPED_VIA_GEN' }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run<{ escaped?: boolean; blocked?: boolean; error?: string }>(code); + + // Should be blocked by validation + if (result.success && result.value) { + expect(result.value.escaped).not.toBe(true); + } + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SOE-17: Expanded Stack Overflow Error Sources', () => { + it('should block escape via mutual recursion stack overflow', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + let capturedError; + + // Mutual recursion: a() -> b() -> a() -> ... + function a() { + try { + b(); + } catch (e) { + capturedError = e; + } + } + function b() { + a(); + } + + a(); + + if (!capturedError) { + return { blocked: true, reason: 'no_error_captured' }; + } + + // Try to escape via captured error + try { + const F = capturedError.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_MUTUAL_RECURSION"'); + return { escaped: fn() === 'ESCAPED_MUTUAL_RECURSION' }; + } + return { blocked: true, reason: 'not_function' }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run<{ escaped?: boolean; blocked?: boolean; error?: string }>(code); + + if (result.success && result.value) { + expect(result.value.escaped).not.toBe(true); + } + + // Verify host is safe + expect((Object.prototype as Record<string, unknown>)['mutualRecursion']).toBeUndefined(); + enclave.dispose(); + }, 15000); + + it('should block escape via promise chain recursion', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + let capturedError; + + // Promise-based recursion + async function promiseRecurse() { + try { + await promiseRecurse(); + } catch (e) { + capturedError = e; + throw e; // Re-throw to propagate + } + } + + try { + await promiseRecurse(); + } catch (e) { + // Expected to fail with stack overflow + } + + if (!capturedError) { + return { blocked: true, reason: 'no_error_captured' }; + } + + // Try to escape + try { + const F = capturedError.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_PROMISE_RECURSION"'); + return { escaped: fn() === 'ESCAPED_PROMISE_RECURSION' }; + } + return { blocked: true, reason: 'not_function' }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run<{ escaped?: boolean; blocked?: boolean; error?: string }>(code); + + if (result.success && result.value) { + expect(result.value.escaped).not.toBe(true); + } + enclave.dispose(); + }, 15000); + + it('should block escape via generator yield* recursion', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + let capturedError; + + function* genRecurse() { + try { + yield* genRecurse(); + } catch (e) { + capturedError = e; + } + } + + try { + const gen = genRecurse(); + // Exhaust the generator + while (true) { + const result = gen.next(); + if (result.done) break; + } + } catch (e) { + // Expected + } + + if (!capturedError) { + return { blocked: true, reason: 'no_error_captured' }; + } + + // Try to escape + try { + const F = capturedError.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_GENERATOR_RECURSION"'); + return { escaped: fn() === 'ESCAPED_GENERATOR_RECURSION' }; + } + return { blocked: true, reason: 'not_function' }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run<{ escaped?: boolean; blocked?: boolean; error?: string }>(code); + + if (result.success && result.value) { + expect(result.value.escaped).not.toBe(true); + } + enclave.dispose(); + }, 15000); + }); + + describe('ATK-SOE-V8: V8-generated RangeError realm verification', () => { + it('should verify __proto__ shadowing works on V8-generated stack overflow errors', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + // Trigger a real V8-generated stack overflow RangeError + let v8Error; + const triggerOverflow = () => { + try { + triggerOverflow(); + } catch (e) { + v8Error = e; + } + }; + triggerOverflow(); + + if (!v8Error) { + return { error: 'Failed to capture V8 RangeError' }; + } + + const results = { + errorName: v8Error.name, + errorMessage: v8Error.message?.substring(0, 50), + // Test 1: __proto__ should return null (shadowed) + protoIsNull: v8Error.__proto__ === null, + protoValue: String(v8Error.__proto__), + // Test 2: Try to traverse prototype chain - should fail + protoChainBlocked: false, + // Test 3: Object.getPrototypeOf behavior + getPrototypeOfResult: null, + // Test 4: Verify constructor.constructor doesn't give us Function + constructorChainBlocked: false + }; + + // Test __proto__ chain traversal + try { + const proto1 = v8Error.__proto__; + if (proto1 === null) { + results.protoChainBlocked = true; + } else { + const proto2 = proto1.__proto__; + const proto3 = proto2?.__proto__; + // If we got here without null, check if proto3 is Object.prototype + results.protoChainBlocked = proto3 === null; + } + } catch (e) { + results.protoChainBlocked = true; + } + + // Test Object.getPrototypeOf + try { + const realProto = Object.getPrototypeOf(v8Error); + results.getPrototypeOfResult = realProto ? 'has_prototype' : 'null_prototype'; + } catch (e) { + results.getPrototypeOfResult = 'error: ' + e.name; + } + + // Test constructor chain for Function access + try { + const ctor = v8Error.constructor; + const F = ctor?.constructor; + if (typeof F === 'function') { + // Try to use it + const fn = F('return "ESCAPED"'); + const fnResult = fn(); + results.constructorChainBlocked = fnResult !== 'ESCAPED'; + } else { + results.constructorChainBlocked = true; + } + } catch (e) { + results.constructorChainBlocked = true; + } + + return results; + `; + + const result = await enclave.run<{ + errorName: string; + errorMessage: string; + protoIsNull: boolean; + protoValue: string; + protoChainBlocked: boolean; + getPrototypeOfResult: string | null; + constructorChainBlocked: boolean; + }>(code); + + // ALWAYS verify - never skip + if (result.success && result.value) { + // Verify we caught a RangeError (stack overflow) + expect(result.value.errorName).toBe('RangeError'); + + // __proto__ SHOULD return null due to shadowing + expect(result.value.protoIsNull).toBe(true); + expect(result.value.protoValue).toBe('null'); + + // Prototype chain traversal via __proto__ should be blocked + expect(result.value.protoChainBlocked).toBe(true); + + // Constructor chain should be blocked (can't escape to Function) + expect(result.value.constructorChainBlocked).toBe(true); + } else { + // If execution failed, it should be due to security measures + expect(result.error).toBeDefined(); + expect( + result.error?.code === 'VALIDATION_ERROR' || + result.error?.message?.includes('blocked') || + result.error?.name === 'TypeError' || + result.error?.name === 'EvalError', + ).toBe(true); + } + + // Verify host Object.prototype is never polluted + const hostObj: Record<string, unknown> = {}; + expect(hostObj['v8Escape']).toBeUndefined(); + + enclave.dispose(); + }, 15000); + + it('should verify __proto__ shadowing persists across multiple stack overflows', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + const results = []; + + // Trigger multiple stack overflows and check each error + for (let attempt = 0; attempt < 3; attempt++) { + let error; + const overflow = () => { + try { overflow(); } catch (e) { error = e; } + }; + overflow(); + + if (error) { + results.push({ + attempt, + protoIsNull: error.__proto__ === null, + name: error.name + }); + } + } + + return results; + `; + + const result = await enclave.run<Array<{ attempt: number; protoIsNull: boolean; name: string }>>(code); + + if (result.success && Array.isArray(result.value)) { + // All attempts should show __proto__ returning null + for (const r of result.value) { + expect(r.protoIsNull).toBe(true); + expect(r.name).toBe('RangeError'); + } + } else { + // Security failure is acceptable + expect(result.error).toBeDefined(); + } + + enclave.dispose(); + }, 20000); + }); + + describe('ATK-SOE-18: AggregateError and Error.cause Escape Vectors', () => { + it('should block escape via AggregateError.errors[].constructor.constructor', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + // Create AggregateError with inner errors + const innerError = new Error('inner'); + const aggError = new AggregateError([innerError], 'aggregate'); + + try { + // Try to escape via the errors array + const firstError = aggError.errors[0]; + const F = firstError.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_VIA_AGGREGATE_ERROR"'); + return { escaped: fn() === 'ESCAPED_VIA_AGGREGATE_ERROR' }; + } + return { blocked: true, reason: 'not_function' }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run<{ escaped?: boolean; blocked?: boolean; error?: string }>(code); + + if (result.success && result.value) { + expect(result.value.escaped).not.toBe(true); + } + enclave.dispose(); + }, 15000); + + it('should block escape via Error.cause chain traversal', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + // Create nested error with cause chain + const rootCause = new Error('root cause'); + const middleError = new Error('middle', { cause: rootCause }); + const outerError = new Error('outer', { cause: middleError }); + + try { + // Traverse the cause chain and try to escape + const cause = outerError.cause; + const deepCause = cause.cause; + const F = deepCause.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_VIA_ERROR_CAUSE"'); + return { escaped: fn() === 'ESCAPED_VIA_ERROR_CAUSE' }; + } + return { blocked: true, reason: 'not_function' }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run<{ escaped?: boolean; blocked?: boolean; error?: string }>(code); + + if (result.success && result.value) { + expect(result.value.escaped).not.toBe(true); + } + enclave.dispose(); + }, 15000); + + it('should block escape via stack overflow error stored in AggregateError', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + let stackOverflowError; + const recurse = () => { + try { + recurse(); + } catch (e) { + stackOverflowError = e; + } + }; + recurse(); + + if (!stackOverflowError) { + return { blocked: true, reason: 'no_stack_overflow_error' }; + } + + // Wrap the stack overflow error in AggregateError + const aggError = new AggregateError([stackOverflowError], 'wrapped overflow'); + + try { + // Try to escape via the wrapped error + const wrappedError = aggError.errors[0]; + const F = wrappedError.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_VIA_WRAPPED_OVERFLOW"'); + return { escaped: fn() === 'ESCAPED_VIA_WRAPPED_OVERFLOW' }; + } + return { blocked: true, reason: 'not_function' }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run<{ escaped?: boolean; blocked?: boolean; error?: string }>(code); + + if (result.success && result.value) { + expect(result.value.escaped).not.toBe(true); + } + enclave.dispose(); + }, 15000); + + it('should block escape via stack overflow error as cause', async () => { + const enclave = new Enclave({ timeout: 5000 }); + const code = ` + let stackOverflowError; + const recurse = () => { + try { + recurse(); + } catch (e) { + stackOverflowError = e; + } + }; + recurse(); + + if (!stackOverflowError) { + return { blocked: true, reason: 'no_stack_overflow_error' }; + } + + // Use stack overflow error as cause + const wrapperError = new Error('wrapper', { cause: stackOverflowError }); + + try { + // Try to escape via the cause + const cause = wrapperError.cause; + const F = cause.constructor.constructor; + if (typeof F === 'function') { + const fn = F('return "ESCAPED_VIA_CAUSE_OVERFLOW"'); + return { escaped: fn() === 'ESCAPED_VIA_CAUSE_OVERFLOW' }; + } + return { blocked: true, reason: 'not_function' }; + } catch (e) { + return { blocked: true, error: e.name }; + } + `; + + const result = await enclave.run<{ escaped?: boolean; blocked?: boolean; error?: string }>(code); + + if (result.success && result.value) { + expect(result.value.escaped).not.toBe(true); + } + enclave.dispose(); + }, 15000); + }); +});
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-x39w-8vm5-5m3pghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-25533ghsaADVISORY
- github.com/agentfront/enclave/commit/2fcf5da81e7e2578ede6f94cae4f379165426dcaghsax_refsource_MISCWEB
- github.com/agentfront/enclave/security/advisories/GHSA-x39w-8vm5-5m3pghsax_refsource_CONFIRMWEB
- www.staicu.org/publications/usenixSec2023-SandDriller.pdfghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.