From 6a2ec92e222437750184e3f2204886b86be4e835 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Feb 2026 16:32:01 +0200 Subject: [PATCH 1/6] feat: implement security hardening measures against stack overflow escape attacks --- .../ast/src/rules/resource-exhaustion.rule.ts | 66 + .../__tests__/enclave.advanced-escape.spec.ts | 1781 +++++++++++++++++ .../__tests__/enclave.attack-matrix.spec.ts | 363 ++++ .../enclave.stack-overflow-escape.spec.ts | 1573 +++++++++++++++ .../core/src/double-vm/parent-vm-bootstrap.ts | 201 +- 5 files changed, 3982 insertions(+), 2 deletions(-) create mode 100644 libs/core/src/__tests__/enclave.advanced-escape.spec.ts create mode 100644 libs/core/src/__tests__/enclave.stack-overflow-escape.spec.ts diff --git a/libs/ast/src/rules/resource-exhaustion.rule.ts b/libs/ast/src/rules/resource-exhaustion.rule.ts index c4e4241..3b904b2 100644 --- a/libs/ast/src/rules/resource-exhaustion.rule.ts +++ b/libs/ast/src/rules/resource-exhaustion.rule.ts @@ -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,58 @@ 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(...) - always suspicious in computed property context + if ( + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'String' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'fromCharCode' + ) { + 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)) { + 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 diff --git a/libs/core/src/__tests__/enclave.advanced-escape.spec.ts b/libs/core/src/__tests__/enclave.advanced-escape.spec.ts new file mode 100644 index 0000000..91a99fc --- /dev/null +++ b/libs/core/src/__tests__/enclave.advanced-escape.spec.ts @@ -0,0 +1,1781 @@ +/** + * 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>(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>(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>(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>(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>(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>(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>(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>(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>(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(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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); + }); +}); diff --git a/libs/core/src/__tests__/enclave.attack-matrix.spec.ts b/libs/core/src/__tests__/enclave.attack-matrix.spec.ts index c4209c0..4295908 100644 --- a/libs/core/src/__tests__/enclave.attack-matrix.spec.ts +++ b/libs/core/src/__tests__/enclave.attack-matrix.spec.ts @@ -1339,4 +1339,367 @@ 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 + }; + `; + + 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'); + } + } + + 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 = {}; + 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)['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); + }); }); diff --git a/libs/core/src/__tests__/enclave.stack-overflow-escape.spec.ts b/libs/core/src/__tests__/enclave.stack-overflow-escape.spec.ts new file mode 100644 index 0000000..9408a0a --- /dev/null +++ b/libs/core/src/__tests__/enclave.stack-overflow-escape.spec.ts @@ -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 = {}; + 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)['hostPolluted']).toBeUndefined(); + expect((Array.prototype as unknown as Record)['hostPolluted']).toBeUndefined(); + + // Verify by creating new objects + const newObj: Record = {}; + 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 = {}; + 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)['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)['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)['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>(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)['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>(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)['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 = {}; + 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>(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); + }); +}); diff --git a/libs/core/src/double-vm/parent-vm-bootstrap.ts b/libs/core/src/double-vm/parent-vm-bootstrap.ts index bf2a5da..d1e50a1 100644 --- a/libs/core/src/double-vm/parent-vm-bootstrap.ts +++ b/libs/core/src/double-vm/parent-vm-bootstrap.ts @@ -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,63 @@ ${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.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 +1919,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 From 63d101539bf31029d3db3882e491c936a9070385 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Feb 2026 17:00:41 +0200 Subject: [PATCH 2/6] feat: enhance test workflow with matrix strategy and improved result aggregation --- .github/workflows/push.yml | 123 +++++++++++------- .../ast/src/rules/resource-exhaustion.rule.ts | 8 +- .../__tests__/enclave.attack-matrix.spec.ts | 30 +++-- .../core/src/double-vm/parent-vm-bootstrap.ts | 1 + 4 files changed, 98 insertions(+), 64 deletions(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index f5699eb..6394cf1 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -64,11 +64,36 @@ jobs: key: build-${{ github.sha }} test: - name: Test + name: Test (${{ matrix.name }}) runs-on: ubuntu-latest needs: build env: NX_DAEMON: "false" + strategy: + fail-fast: false + matrix: + include: + - name: core-unit + command: npx nx test core --passWithNoTests + coverage: true + - name: ast + command: npx nx test ast --passWithNoTests + coverage: true + - name: broker + command: npx nx test broker --passWithNoTests + coverage: true + - name: client + command: npx nx test client --passWithNoTests + coverage: true + - name: react + command: npx nx test react --passWithNoTests + coverage: true + - name: runtime + command: npx nx test runtime --passWithNoTests + coverage: true + - name: core-perf + command: npx nx run core:test-perf --passWithNoTests + coverage: false steps: - name: Checkout code @@ -90,79 +115,79 @@ jobs: libs/*/dist key: build-${{ github.sha }} - - name: Set Nx SHAs - uses: nrwl/nx-set-shas@v4 - - - name: Test libraries + - name: Run ${{ matrix.name }} tests id: test - run: npx nx affected -t test --passWithNoTests 2>&1 | tee test-output.txt + run: ${{ matrix.command }} 2>&1 | tee test-output.txt continue-on-error: true - name: Log failed tests to summary if: steps.test.outcome == 'failure' run: | - echo "## ❌ Test Failures" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY + echo "## ❌ ${{ matrix.name }} Test Failures" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - # Extract failed test names and error messages grep -A 5 "FAIL\|✕\|Error:" test-output.txt | head -100 >> $GITHUB_STEP_SUMMARY || echo "Could not extract failure details" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.name }} + path: | + test-output.txt + coverage/ + perf-results.json + if-no-files-found: ignore - name: Fail if tests failed if: steps.test.outcome == 'failure' run: exit 1 - performance: - name: Performance + aggregate-results: + name: Aggregate Results runs-on: ubuntu-latest - needs: build - env: - NX_DAEMON: "false" - + needs: test + if: always() steps: - name: Checkout code uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version-file: ".nvmrc" - cache: "yarn" - - name: Restore build artifacts - uses: actions/cache/restore@v4 + - name: Download all test artifacts + uses: actions/download-artifact@v4 with: - path: | - node_modules - libs/*/dist - key: build-${{ github.sha }} + pattern: test-results-* + path: all-results + merge-multiple: false - - name: Run performance tests - run: npx nx run core:test-perf --passWithNoTests - continue-on-error: true - - - name: Generate performance summary - if: always() + - name: Generate combined summary run: | - if [ -f "perf-results.json" ]; then - echo "## Performance Test Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - node scripts/format-perf-summary.mjs perf-results.json >> $GITHUB_STEP_SUMMARY - else - echo "## Performance Tests" >> $GITHUB_STEP_SUMMARY + echo "## 📊 Test Results Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Check each test result + for dir in all-results/test-results-*; do + name=$(basename "$dir" | sed 's/test-results-//') + if [ -f "$dir/test-output.txt" ]; then + if grep -q "FAIL\|✕" "$dir/test-output.txt"; then + echo "- ❌ **$name**: Failed" >> $GITHUB_STEP_SUMMARY + else + echo "- ✅ **$name**: Passed" >> $GITHUB_STEP_SUMMARY + fi + else + echo "- ⏭️ **$name**: Skipped" >> $GITHUB_STEP_SUMMARY + fi + done + + # Performance results + if [ -f "all-results/test-results-core-perf/perf-results.json" ]; then echo "" >> $GITHUB_STEP_SUMMARY - echo "No performance results found. Tests may have been skipped or failed to run." >> $GITHUB_STEP_SUMMARY + echo "### Performance Results" >> $GITHUB_STEP_SUMMARY + node scripts/format-perf-summary.mjs all-results/test-results-core-perf/perf-results.json >> $GITHUB_STEP_SUMMARY || true fi - continue-on-error: true - - name: Upload performance results - if: always() + - name: Upload combined artifact uses: actions/upload-artifact@v4 with: - name: perf-results - path: perf-results.json - if-no-files-found: ignore + name: all-test-results + path: all-results/ retention-days: 30 diff --git a/libs/ast/src/rules/resource-exhaustion.rule.ts b/libs/ast/src/rules/resource-exhaustion.rule.ts index 3b904b2..f0394ed 100644 --- a/libs/ast/src/rules/resource-exhaustion.rule.ts +++ b/libs/ast/src/rules/resource-exhaustion.rule.ts @@ -335,7 +335,13 @@ export class ResourceExhaustionRule implements ValidationRule { if (arr.elements.length === 1 && arr.elements[0]?.type === 'Literal') { const value = String(arr.elements[0].value).toLowerCase(); if (dangerousStrings.includes(value)) { - return true; + // 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; + } } } } diff --git a/libs/core/src/__tests__/enclave.attack-matrix.spec.ts b/libs/core/src/__tests__/enclave.attack-matrix.spec.ts index 4295908..356221d 100644 --- a/libs/core/src/__tests__/enclave.attack-matrix.spec.ts +++ b/libs/core/src/__tests__/enclave.attack-matrix.spec.ts @@ -1453,22 +1453,24 @@ describe('Enclave Attack Matrix', () => { }; `; - 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'); + 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(); } - - enclave.dispose(); }, 20000); it('ATK-SOE-01: should block Object.prototype modification via stack overflow', async () => { diff --git a/libs/core/src/double-vm/parent-vm-bootstrap.ts b/libs/core/src/double-vm/parent-vm-bootstrap.ts index d1e50a1..dce94a6 100644 --- a/libs/core/src/double-vm/parent-vm-bootstrap.ts +++ b/libs/core/src/double-vm/parent-vm-bootstrap.ts @@ -1712,6 +1712,7 @@ ${stackTraceHardeningCode} ' return err;' + ' };' + ' WrappedCtor.prototype = OrigCtor.prototype;' + + ' try { Object.setPrototypeOf(WrappedCtor, OrigCtor); } catch (e) {}' + ' try { Object.freeze(WrappedCtor); } catch (e) {}' + ' return WrappedCtor;' + ' }' + From c6194cb3d2813d4b04f0376e5559aca97ebbb061 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Feb 2026 17:27:57 +0200 Subject: [PATCH 3/6] feat: enhance test workflow with additional performance test configurations and improved result aggregation --- .github/workflows/push.yml | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 6394cf1..804e0fe 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -74,7 +74,10 @@ jobs: matrix: include: - name: core-unit - command: npx nx test core --passWithNoTests + command: npx nx test core --passWithNoTests --testPathIgnorePatterns="attack|security|escape|ssrf|redos|gadget|exhaustion|flood|obfuscation" + coverage: true + - name: core-security + command: npx nx test core --passWithNoTests --testPathPattern="attack|security|escape|ssrf|redos|gadget|exhaustion|flood|obfuscation" coverage: true - name: ast command: npx nx test ast --passWithNoTests @@ -91,8 +94,17 @@ jobs: - name: runtime command: npx nx test runtime --passWithNoTests coverage: true - - name: core-perf - command: npx nx run core:test-perf --passWithNoTests + - name: core-perf-babel + command: npx nx run core:test-perf --passWithNoTests --testPathPattern="babel.perf" + coverage: false + - name: core-perf-enclave + command: npx nx run core:test-perf --passWithNoTests --testPathPattern="enclave.perf" + coverage: false + - name: core-perf-double-vm + command: npx nx run core:test-perf --passWithNoTests --testPathPattern="double-vm.perf" + coverage: false + - name: core-perf-worker-pool + command: npx nx run core:test-perf --passWithNoTests --testPathPattern="worker-pool.perf" coverage: false steps: @@ -179,11 +191,15 @@ jobs: done # Performance results - if [ -f "all-results/test-results-core-perf/perf-results.json" ]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Performance Results" >> $GITHUB_STEP_SUMMARY - node scripts/format-perf-summary.mjs all-results/test-results-core-perf/perf-results.json >> $GITHUB_STEP_SUMMARY || true - fi + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Performance Results" >> $GITHUB_STEP_SUMMARY + for perf_dir in all-results/test-results-core-perf-*; do + if [ -f "$perf_dir/perf-results.json" ]; then + perf_name=$(basename "$perf_dir" | sed 's/test-results-core-perf-//') + echo "#### $perf_name" >> $GITHUB_STEP_SUMMARY + node scripts/format-perf-summary.mjs "$perf_dir/perf-results.json" >> $GITHUB_STEP_SUMMARY || true + fi + done - name: Upload combined artifact uses: actions/upload-artifact@v4 From 6d4b208193c3b77d7027f82fcb37db45611e6d3f Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Feb 2026 18:17:02 +0200 Subject: [PATCH 4/6] feat: enhance test execution and improve resource exhaustion rule checks --- .github/workflows/push.yml | 12 +++++++++++- libs/ast/src/rules/resource-exhaustion.rule.ts | 14 +++++++++----- .../src/__tests__/enclave.advanced-escape.spec.ts | 1 + 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 804e0fe..d9bddce 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -127,9 +127,19 @@ jobs: libs/*/dist key: build-${{ github.sha }} + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Run ${{ matrix.name }} tests id: test - run: ${{ matrix.command }} 2>&1 | tee test-output.txt + shell: bash + run: | + set -o pipefail + COMMAND="${{ matrix.command }}" + if [ "${{ matrix.coverage }}" = "true" ]; then + COMMAND="$COMMAND --coverage" + fi + $COMMAND 2>&1 | tee test-output.txt continue-on-error: true - name: Log failed tests to summary diff --git a/libs/ast/src/rules/resource-exhaustion.rule.ts b/libs/ast/src/rules/resource-exhaustion.rule.ts index f0394ed..6d2df79 100644 --- a/libs/ast/src/rules/resource-exhaustion.rule.ts +++ b/libs/ast/src/rules/resource-exhaustion.rule.ts @@ -318,15 +318,19 @@ export class ResourceExhaustionRule implements ValidationRule { } } - // String.fromCharCode(...) - always suspicious in computed property context + // 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' && - node.callee.property.type === 'Identifier' && - node.callee.property.name === 'fromCharCode' + node.callee.object.name === 'String' ) { - return true; + 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('') diff --git a/libs/core/src/__tests__/enclave.advanced-escape.spec.ts b/libs/core/src/__tests__/enclave.advanced-escape.spec.ts index 91a99fc..4df3515 100644 --- a/libs/core/src/__tests__/enclave.advanced-escape.spec.ts +++ b/libs/core/src/__tests__/enclave.advanced-escape.spec.ts @@ -489,6 +489,7 @@ describe('Advanced Sandbox Escape Prevention', () => { // 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(); From 0aafe723dff9d0101161211f2f81f25281888b17 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Feb 2026 18:31:28 +0200 Subject: [PATCH 5/6] feat: simplify test workflow and enhance performance test reporting --- .github/workflows/push.yml | 151 ++++++++++++------------------------- 1 file changed, 50 insertions(+), 101 deletions(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index d9bddce..f5699eb 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -64,48 +64,11 @@ jobs: key: build-${{ github.sha }} test: - name: Test (${{ matrix.name }}) + name: Test runs-on: ubuntu-latest needs: build env: NX_DAEMON: "false" - strategy: - fail-fast: false - matrix: - include: - - name: core-unit - command: npx nx test core --passWithNoTests --testPathIgnorePatterns="attack|security|escape|ssrf|redos|gadget|exhaustion|flood|obfuscation" - coverage: true - - name: core-security - command: npx nx test core --passWithNoTests --testPathPattern="attack|security|escape|ssrf|redos|gadget|exhaustion|flood|obfuscation" - coverage: true - - name: ast - command: npx nx test ast --passWithNoTests - coverage: true - - name: broker - command: npx nx test broker --passWithNoTests - coverage: true - - name: client - command: npx nx test client --passWithNoTests - coverage: true - - name: react - command: npx nx test react --passWithNoTests - coverage: true - - name: runtime - command: npx nx test runtime --passWithNoTests - coverage: true - - name: core-perf-babel - command: npx nx run core:test-perf --passWithNoTests --testPathPattern="babel.perf" - coverage: false - - name: core-perf-enclave - command: npx nx run core:test-perf --passWithNoTests --testPathPattern="enclave.perf" - coverage: false - - name: core-perf-double-vm - command: npx nx run core:test-perf --passWithNoTests --testPathPattern="double-vm.perf" - coverage: false - - name: core-perf-worker-pool - command: npx nx run core:test-perf --passWithNoTests --testPathPattern="worker-pool.perf" - coverage: false steps: - name: Checkout code @@ -127,93 +90,79 @@ 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 - - name: Run ${{ matrix.name }} tests + - name: Test libraries id: test - shell: bash - run: | - set -o pipefail - COMMAND="${{ matrix.command }}" - if [ "${{ matrix.coverage }}" = "true" ]; then - COMMAND="$COMMAND --coverage" - fi - $COMMAND 2>&1 | tee test-output.txt + run: npx nx affected -t test --passWithNoTests 2>&1 | tee test-output.txt continue-on-error: true - name: Log failed tests to summary if: steps.test.outcome == 'failure' run: | - echo "## ❌ ${{ matrix.name }} Test Failures" >> $GITHUB_STEP_SUMMARY + echo "## ❌ Test Failures" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY + # Extract failed test names and error messages grep -A 5 "FAIL\|✕\|Error:" test-output.txt | head -100 >> $GITHUB_STEP_SUMMARY || echo "Could not extract failure details" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-results-${{ matrix.name }} - path: | - test-output.txt - coverage/ - perf-results.json - if-no-files-found: ignore + echo "" >> $GITHUB_STEP_SUMMARY - name: Fail if tests failed if: steps.test.outcome == 'failure' run: exit 1 - aggregate-results: - name: Aggregate Results + performance: + name: Performance runs-on: ubuntu-latest - needs: test - if: always() + needs: build + env: + NX_DAEMON: "false" + steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Download all test artifacts - uses: actions/download-artifact@v4 + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + cache: "yarn" + + - name: Restore build artifacts + uses: actions/cache/restore@v4 with: - pattern: test-results-* - path: all-results - merge-multiple: false + path: | + node_modules + libs/*/dist + key: build-${{ github.sha }} + + - name: Run performance tests + run: npx nx run core:test-perf --passWithNoTests + continue-on-error: true - - name: Generate combined summary + - name: Generate performance summary + if: always() run: | - echo "## 📊 Test Results Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY + if [ -f "perf-results.json" ]; then + echo "## Performance Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + node scripts/format-perf-summary.mjs perf-results.json >> $GITHUB_STEP_SUMMARY + else + echo "## Performance Tests" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "No performance results found. Tests may have been skipped or failed to run." >> $GITHUB_STEP_SUMMARY + fi + continue-on-error: true - # Check each test result - for dir in all-results/test-results-*; do - name=$(basename "$dir" | sed 's/test-results-//') - if [ -f "$dir/test-output.txt" ]; then - if grep -q "FAIL\|✕" "$dir/test-output.txt"; then - echo "- ❌ **$name**: Failed" >> $GITHUB_STEP_SUMMARY - else - echo "- ✅ **$name**: Passed" >> $GITHUB_STEP_SUMMARY - fi - else - echo "- ⏭️ **$name**: Skipped" >> $GITHUB_STEP_SUMMARY - fi - done - - # Performance results - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Performance Results" >> $GITHUB_STEP_SUMMARY - for perf_dir in all-results/test-results-core-perf-*; do - if [ -f "$perf_dir/perf-results.json" ]; then - perf_name=$(basename "$perf_dir" | sed 's/test-results-core-perf-//') - echo "#### $perf_name" >> $GITHUB_STEP_SUMMARY - node scripts/format-perf-summary.mjs "$perf_dir/perf-results.json" >> $GITHUB_STEP_SUMMARY || true - fi - done - - - name: Upload combined artifact + - name: Upload performance results + if: always() uses: actions/upload-artifact@v4 with: - name: all-test-results - path: all-results/ + name: perf-results + path: perf-results.json + if-no-files-found: ignore retention-days: 30 From a252ae09573b294ebf07d6004cb2ca3be5650e3c Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Feb 2026 18:38:11 +0200 Subject: [PATCH 6/6] feat: add dependency installation step to CI workflow --- .github/workflows/push.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index f5699eb..9497cf9 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -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: