diff --git a/packages/plugins/@nocobase/plugin-workflow-javascript/.env.example b/packages/plugins/@nocobase/plugin-workflow-javascript/.env.example new file mode 100644 index 00000000000..b8ef5607835 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-javascript/.env.example @@ -0,0 +1,15 @@ +# This is an example of environment variables for the plugin-workflow-javascript plugin. +# You can merge this file with your existing .env file and customize the values as needed. +# +# --- SECURITY WARNING --- +# * By default, no modules are allowed to be used in the JavaScript node. Your script will be executed in a sandboxed environment by `isolated-vm`. +# +# * By setting any module in WORKFLOW_SCRIPT_MODULES, the JavaScript node will use `node:vm` to execute the script, +# which will cause potential security risks. Please make sure the script content is managed by privileged users, +# and use it with caution. This will be at your own risk. +# +# See the documentation for more details: https://docs.nocobase.com/workflow/nodes/javascript#safe-mode-default +# +# Example: lodash,dayjs +# WORKFLOW_SCRIPT_MODULES=lodash,dayjs +WORKFLOW_SCRIPT_MODULES= diff --git a/packages/plugins/@nocobase/plugin-workflow-javascript/src/server/Vm.js b/packages/plugins/@nocobase/plugin-workflow-javascript/src/server/Vm.js index fbd4cc36b1a..140568da20b 100644 --- a/packages/plugins/@nocobase/plugin-workflow-javascript/src/server/Vm.js +++ b/packages/plugins/@nocobase/plugin-workflow-javascript/src/server/Vm.js @@ -13,44 +13,306 @@ const Path = require('node:path'); let timer = null; -function customRequire(m) { - const configuredModules = (process.env.WORKFLOW_SCRIPT_MODULES?.split(',') ?? []).filter(Boolean); - let mainName; - if (Path.isAbsolute(m)) { - // absolute path - mainName = m; - } else if (m.startsWith('.')) { - // relative path - mainName = m; - m = Path.resolve(process.cwd(), m); - } else { - mainName = m - .split('/') - .slice(0, m.startsWith('@') ? 2 : 1) - .join('/'); - } - if (configuredModules.includes(mainName)) { - return require(m); - } - throw new Error(`module "${m}" not supported`); -} - /** - * Sever the prototype chain of a host-realm function so that - * Object.getPrototypeOf(fn).constructor cannot reach the host Function constructor. + * Sever the prototype chain of a host-realm value (function or object) so that + * Object.getPrototypeOf(v).constructor cannot reach the host Function constructor. + * Works on functions, plain objects, and Error instances alike; setPrototypeOf(null) + * is a no-op when the prototype is already null. */ -function hardenFunction(fn) { - Object.setPrototypeOf(fn, null); - Object.defineProperty(fn, 'constructor', { +function harden(v) { + Object.setPrototypeOf(v, null); + Object.defineProperty(v, 'constructor', { value: null, writable: false, enumerable: false, configurable: false, }); - return fn; + return v; } -hardenFunction(customRequire); +/** + * Build a sandbox-safe Error. Used whenever an error originating in the host realm + * needs to cross back into the sandbox — throwing a raw host Error would expose + * e.constructor.constructor === host Function, enabling RCE. + */ +function createSandboxError(msg) { + return harden(new Error(msg)); +} + +/** + * Read an own property value from an object, invoking the getter if the property + * is an accessor descriptor. Returns undefined for write-only or throwing accessors. + */ +function resolveProperty(obj, key) { + const desc = Object.getOwnPropertyDescriptor(obj, key); + if (!desc) return undefined; + if ('value' in desc) return desc.value; + if (desc.get) { + try { + return desc.get.call(obj); + } catch (_) { + return undefined; + } + } + return undefined; +} + +/** + * Sanitize a thrown value from the host realm so that its prototype chain cannot + * be used to escape the sandbox. The message is extracted as a primitive string, + * then a fresh Error is created whose prototype chain is severed. + */ +function sanitizeError(e) { + let msg; + try { + msg = e != null && typeof e.message === 'string' ? e.message : String(e); + } catch (_) { + msg = 'unknown error'; + } + return createSandboxError(msg); +} + +/** + * Sanitize a value returned from a host-realm function call before it re-enters + * the sandbox. Handles both synchronous return values and Promises (thenables). + * + * For thenables, a clean null-prototype thenable is returned instead of the raw + * host Promise. Returning the host Promise directly would expose + * p.constructor (→ host Promise) → p.constructor.constructor (→ host Function), + * allowing sandbox escape. The clean thenable intercepts both onFulfilled and + * onRejected callbacks so that resolved values are sanitized and rejected errors + * have their prototype chains severed before reaching sandbox code. + */ +function sanitizeReturnValue(result) { + if (result === null || result === undefined) return result; + if (typeof result !== 'object' && typeof result !== 'function') return result; + + if (typeof result.then === 'function') { + // Build a null-prototype thenable that wraps the host Promise without + // exposing its constructor chain. `await cleanThenable` works because the + // spec resolves thenables by calling cleanThenable.then(resolve, reject). + const cleanThenable = Object.create(null); + const originalThen = result.then.bind(result); + Object.defineProperty(cleanThenable, 'then', { + value: harden(function (onFulfilled, onRejected) { + const next = originalThen( + typeof onFulfilled === 'function' + ? (v) => onFulfilled(sanitizeForSandbox(v, null, new WeakMap())) + : onFulfilled, + typeof onRejected === 'function' ? (e) => onRejected(sanitizeError(e)) : onRejected, + ); + // Sanitize the chained Promise so that .then().then() chains are also clean. + return sanitizeReturnValue(next); + }), + writable: false, + enumerable: true, + configurable: false, + }); + return cleanThenable; + } + + return sanitizeForSandbox(result, null, new WeakMap()); +} + +/** + * Recursively sanitize a value from the host realm before exposing it to the VM + * sandbox context. + * + * Security guarantee: every function in the returned structure has its prototype + * chain severed and its `constructor` property set to null via harden(), + * so that sandbox code cannot traverse `fn.constructor` (or + * `fn.constructor.constructor`) to reach the host Function constructor. + * + * All object values are re-created with a null prototype, removing the + * `obj.constructor → host Object → Object.constructor → host Function` chain. + * + * Function wrappers sanitize their return values (including Promise resolutions) + * so that objects returned by module calls also have hardened prototype chains. + * + * Static properties on function-typed exports (e.g. `dayjs.extend`) are also + * exposed on the wrapper so that user code can call them. + * + * @param {*} val - the host-realm value to sanitize + * @param {*} parent - parent object used as `this` when binding methods + * @param {WeakMap} [cache] - cycle-detection cache + */ +function sanitizeForSandbox(val, parent, cache) { + if (!cache) cache = new WeakMap(); + + // Primitives (string, number, boolean, bigint, symbol, null, undefined) + // are realm-neutral — they carry no mutable prototype chain. + if (val === null || val === undefined) return val; + if (typeof val !== 'object' && typeof val !== 'function') return val; + + if (cache.has(val)) return cache.get(val); + + if (typeof val === 'function') { + // Bind to parent so that methods relying on `this` keep working + // (e.g. axios.get internally calls this.request). + const fn = parent != null ? val.bind(parent) : val; + const wrapper = function (...args) { + let ret; + try { + ret = fn(...args); + } catch (e) { + // Sanitize host-realm exceptions before they reach the sandbox. + // A raw host Error has e.constructor.constructor === host Function, + // which allows sandbox escape and RCE. + throw sanitizeError(e); + } + return sanitizeReturnValue(ret); + }; + cache.set(val, wrapper); + harden(wrapper); + + // Expose static properties that may be present on function-typed exports + // (e.g. dayjs.extend, lodash.chain, axios.create). + const SKIP_PROPS = new Set(['length', 'name', 'prototype', 'caller', 'arguments', 'constructor']); + for (const key of Object.getOwnPropertyNames(val)) { + if (SKIP_PROPS.has(key)) continue; + try { + const resolved = resolveProperty(val, key); + if (resolved !== undefined) { + Object.defineProperty(wrapper, key, { + value: sanitizeForSandbox(resolved, val, cache), + writable: false, + enumerable: true, + configurable: false, + }); + } + } catch (_) { + // skip non-readable / non-configurable properties + } + } + return wrapper; + } + + // Special-case Arrays: copy indexed elements + length, then attach a safe + // Symbol.iterator so that for...of, spread ([...arr]), and destructuring + // work in sandbox code. We cannot return a real host Array because its + // __proto__.constructor chain leads to the host Function constructor. + // The iterator and its result objects are null-prototype plain objects — + // they do not expose host-realm constructor chains at any level. + if (Array.isArray(val)) { + const len = val.length; + const cleanArr = Object.create(null); + cache.set(val, cleanArr); + + Object.defineProperty(cleanArr, 'length', { + value: len, + writable: false, + enumerable: false, + configurable: false, + }); + + for (let i = 0; i < len; i++) { + try { + cleanArr[i] = sanitizeForSandbox(val[i], null, cache); + } catch (_) { + // skip unreadable slots + } + } + + // Attach Symbol.iterator so for...of / spread / destructuring work. + Object.defineProperty(cleanArr, Symbol.iterator, { + value: harden(function () { + let idx = 0; + const iterator = Object.create(null); + iterator.next = harden(function () { + const result = Object.create(null); + if (idx < len) { + result.value = cleanArr[idx++]; + result.done = false; + } else { + result.value = undefined; + result.done = true; + } + return result; + }); + // The iterator is self-iterable (iterable iterator protocol). + iterator[Symbol.iterator] = harden(function () { + return iterator; + }); + return iterator; + }), + writable: false, + enumerable: false, + configurable: false, + }); + + return cleanArr; + } + + // Object: re-create as a null-prototype plain object and walk the prototype + // chain up to (but excluding) Object.prototype so that inherited methods + // are included (e.g. Hash.prototype.update, EventEmitter.prototype.on, + // Readable.prototype.pipe). Own properties always take precedence over + // inherited ones (first-write wins). + // + // Security note: there is no depth cap. Every copied value is re-sanitized + // recursively, so any function on a deep prototype still gets harden()'d and + // every object still becomes null-prototype. The depth cap that previously + // lived here did not add security; it only silently dropped methods on + // deeper ancestors (e.g. hash.on via EventEmitter, stream.pipe via Readable). + const clean = Object.create(null); + cache.set(val, clean); + + let proto = val; + while (proto !== null && proto !== Object.prototype) { + for (const key of Object.getOwnPropertyNames(proto)) { + if (key === 'constructor' || key in clean) continue; + try { + const resolved = resolveProperty(proto, key); + if (resolved !== undefined) { + // Always bind to the original `val` so `this` inside methods is correct. + clean[key] = sanitizeForSandbox(resolved, val, cache); + } + } catch (_) { + // skip non-readable properties + } + } + proto = Object.getPrototypeOf(proto); + } + return clean; +} + +function customRequire(m) { + // Any exception that escapes this function re-enters the sandbox. Raw host + // errors have e.constructor.constructor === host Function, enabling RCE, so + // wrap the whole body and funnel every throw path through sanitizeError: + // - explicit "not supported" / type errors below + // - TypeErrors from Path.isAbsolute / m.split when m is not a string + // - MODULE_NOT_FOUND from require() itself (e.g. require('fs/bogus') when + // 'fs' is whitelisted but the subpath does not exist) + try { + if (typeof m !== 'string') { + throw new Error('module name must be a string'); + } + const configuredModules = (process.env.WORKFLOW_SCRIPT_MODULES?.split(',') ?? []).filter(Boolean); + let mainName; + if (Path.isAbsolute(m)) { + mainName = m; + } else if (m.startsWith('.')) { + mainName = m; + m = Path.resolve(process.cwd(), m); + } else { + mainName = m + .split('/') + .slice(0, m.startsWith('@') ? 2 : 1) + .join('/'); + } + if (configuredModules.includes(mainName)) { + // Sanitize the module before handing it to the sandbox so that function + // properties have fn.constructor === null. + return sanitizeForSandbox(require(m)); + } + throw new Error(`module "${m}" not supported`); + } catch (e) { + throw sanitizeError(e); + } +} + +harden(customRequire); /** * Create a safe console proxy that only exposes whitelisted logging methods. @@ -85,8 +347,7 @@ function createSafeConsole(originalConsole) { for (const key of allowedMethods) { if (typeof originalConsole[key] === 'function') { - const bound = originalConsole[key].bind(originalConsole); - hardenFunction(bound); + const bound = harden(originalConsole[key].bind(originalConsole)); Object.defineProperty(safe, key, { value: bound, writable: false, @@ -96,14 +357,7 @@ function createSafeConsole(originalConsole) { } } - Object.defineProperty(safe, 'constructor', { - value: null, - writable: false, - enumerable: false, - configurable: false, - }); - - return Object.freeze(safe); + return Object.freeze(harden(safe)); } async function main() { @@ -133,10 +387,37 @@ async function main() { return result; } +/** + * Normalize the script's return value to a plain JSON-compatible value before + * sending it back to the host thread. + * + * Rationale: the job result is always stored in the database as JSON and + * re-parsed by subsequent workflow nodes, so JavaScript-specific semantics + * (prototype chains, Symbols, functions) have no meaning at this boundary. + * A JSON round-trip: + * - converts any null-prototype objects left by sanitizeForSandbox into + * plain objects / proper Arrays that downstream code can use normally, + * - silently drops function-valued properties (expected for serialized data), + * - guarantees structured-clone compatibility for postMessage. + * Primitives and null/undefined pass through unchanged. + */ +function toJsonResult(value) { + if (value === null || value === undefined) return value; + if (typeof value !== 'object' && typeof value !== 'function') return value; + try { + return JSON.parse(JSON.stringify(value)); + } catch (_) { + // Non-JSON-serializable return value (e.g. circular ref, BigInt). + // Fall back to the raw value; postMessage structured clone will either + // handle it or throw its own DataCloneError. + return value; + } +} + // eslint-disable-next-line promise/catch-or-return main() .then((result) => { - parentPort.postMessage({ type: 'result', result }); + parentPort.postMessage({ type: 'result', result: toJsonResult(result) }); // NOTE: due to `process.exit()` will break stdout, it should not be called // see: https://nodejs.org/api/process.html#processexitcode }) diff --git a/packages/plugins/@nocobase/plugin-workflow-javascript/src/server/__tests__/security.test.ts b/packages/plugins/@nocobase/plugin-workflow-javascript/src/server/__tests__/security.test.ts index 81503e35ffd..bfa0c44647a 100644 --- a/packages/plugins/@nocobase/plugin-workflow-javascript/src/server/__tests__/security.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow-javascript/src/server/__tests__/security.test.ts @@ -7,6 +7,8 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import path from 'path'; + import winston from 'winston'; import { JOB_STATUS } from '@nocobase/plugin-workflow'; @@ -143,8 +145,9 @@ describe('workflow-javascript > security > node vm engine (WORKFLOW_SCRIPT_MODUL beforeEach(() => { originalModules = process.env.WORKFLOW_SCRIPT_MODULES; - // Setting WORKFLOW_SCRIPT_MODULES triggers the node vm engine - process.env.WORKFLOW_SCRIPT_MODULES = 'path,crypto'; + // Setting WORKFLOW_SCRIPT_MODULES triggers the node vm engine. + // fs is included so that Promise-returning module APIs can be tested. + process.env.WORKFLOW_SCRIPT_MODULES = 'path,crypto,fs'; transport = new CacheTransport(); logger = winston.createLogger({ transports: [transport], @@ -328,6 +331,136 @@ describe('workflow-javascript > security > node vm engine (WORKFLOW_SCRIPT_MODUL expect(result.result.escaped).toBe(false); }); + it('should not allow sandbox escape via Error thrown by customRequire (Vector 1: Error prototype chain)', async () => { + // Attack: customRequire throws a host-realm Error; sandbox catches it and + // traverses e.constructor.constructor to reach the host Function constructor. + const script = ` + try { + require('__nonexistent_module__'); + } catch (e) { + try { + const F = e.constructor.constructor; + const proc = F('return process')(); + return { escaped: true, pid: proc?.pid }; + } catch (e2) { + return { escaped: false, constructorIsNull: e.constructor === null, detail: String(e2) }; + } + } + return { escaped: false, reason: 'no error thrown' }; + `; + + const result = await ScriptInstruction.run(script, {}, { logger }); + + expect(result.status).toBe(JOB_STATUS.RESOLVED); + expect(result.result.escaped).toBe(false); + expect(result.result.constructorIsNull).toBe(true); + }); + + it('should not allow sandbox escape via module function constructor (Vector 2: fn.constructor)', async () => { + // Attack: a module returned by customRequire is a host-realm object; any + // function property has fn.constructor === host Function constructor. + const script = ` + try { + const path = require('path'); + const F = path.join.constructor; + if (!F) return { escaped: false, reason: 'constructor is null/falsy' }; + const proc = F('return process')(); + return { escaped: true, pid: proc?.pid }; + } catch (e) { + return { escaped: false, error: String(e) }; + } + `; + + const result = await ScriptInstruction.run(script, {}, { logger }); + + expect(result.status).toBe(JOB_STATUS.RESOLVED); + expect(result.result.escaped).toBe(false); + }); + + it('should reproduce the exact PoC from the CVE report and block it (Vector 1 full RCE chain)', async () => { + // Full RCE exploit chain from the report: + // catch host Error → e.constructor.constructor → host Function + // → Function('return process')() → process.binding('spawn_sync').spawn(...) + const script = ` + try { + require('__nonexistent__'); + } catch(e) { + try { + const F = e.constructor.constructor; + const p = F('return process')(); + const s = p.binding('spawn_sync'); + const r = s.spawn({ + file: '/bin/sh', + args: ['/bin/sh', '-c', 'id'], + stdio: [ + { type: 'pipe', readable: true, writable: false }, + { type: 'pipe', readable: false, writable: true }, + { type: 'pipe', readable: false, writable: true } + ] + }); + return { rce: true, output: r.output[1].toString().trim() }; + } catch (e2) { + return { rce: false, error: String(e2) }; + } + } + return { rce: false, reason: 'no error thrown' }; + `; + + const result = await ScriptInstruction.run(script, {}, { logger }); + + expect(result.status).toBe(JOB_STATUS.RESOLVED); + expect(result.result.rce).toBe(false); + }); + + it('should reproduce the exact PoC from the CVE report and block it (Vector 2 full RCE chain)', async () => { + // Full RCE exploit chain via module function constructor: + // path.join.constructor → host Function → Function('return process')() + const script = ` + try { + const path = require('path'); + const F = path.join.constructor; + if (!F) return { rce: false, reason: 'constructor is null' }; + const p = F('return process')(); + const s = p.binding('spawn_sync'); + const r = s.spawn({ + file: '/bin/sh', + args: ['/bin/sh', '-c', 'id'], + stdio: [ + { type: 'pipe', readable: true, writable: false }, + { type: 'pipe', readable: false, writable: true }, + { type: 'pipe', readable: false, writable: true } + ] + }); + return { rce: true, output: r.output[1].toString().trim() }; + } catch (e) { + return { rce: false, error: String(e) }; + } + `; + + const result = await ScriptInstruction.run(script, {}, { logger }); + + expect(result.status).toBe(JOB_STATUS.RESOLVED); + expect(result.result.rce).toBe(false); + }); + + it('module should remain functional after sanitization (path.join)', async () => { + const script = ` + const path = require('path'); + return { + join: path.join('/a', 'b', 'c'), + resolve: typeof path.resolve === 'function', + constructorIsNull: path.join.constructor === null, + }; + `; + + const result = await ScriptInstruction.run(script, {}, { logger }); + + expect(result.status).toBe(JOB_STATUS.RESOLVED); + expect(result.result.join).toBe(path.join('/a', 'b', 'c')); + expect(result.result.resolve).toBe(true); + expect(result.result.constructorIsNull).toBe(true); + }); + it('should not allow escape via Object.getPrototypeOf(console.log).constructor', async () => { // Bound functions retain host-realm prototype chain const script = ` @@ -398,4 +531,202 @@ describe('workflow-javascript > security > node vm engine (WORKFLOW_SCRIPT_MODUL expect(result.status).toBe(JOB_STATUS.RESOLVED); expect(result.result.escaped).toBe(false); }); + + it('should not allow escape via exception thrown by an allowed module function (P1.1: module throw)', async () => { + // Attack: call a whitelist-module function with bad args so it throws a + // host-realm Error, then traverse e.constructor.constructor → host Function. + // Example: path.join(null) throws TypeError in the host realm. + const script = ` + try { + const path = require('path'); + path.join(null); // throws host TypeError + } catch (e) { + try { + const F = e.constructor.constructor; + if (!F) return { escaped: false, reason: 'constructor is null' }; + const proc = F('return process')(); + return { escaped: true, pid: proc?.pid }; + } catch (e2) { + return { escaped: false, constructorIsNull: e.constructor === null, detail: String(e2) }; + } + } + return { escaped: false, reason: 'no error thrown' }; + `; + + const result = await ScriptInstruction.run(script, {}, { logger }); + + expect(result.status).toBe(JOB_STATUS.RESOLVED); + expect(result.result.escaped).toBe(false); + expect(result.result.constructorIsNull).toBe(true); + }); + + it('should not allow escape via Promise returned by an allowed module (P1.2: host Promise constructor)', async () => { + // Attack: an async module API returns a host Promise; p.constructor is the + // host Promise constructor, and p.constructor.constructor is host Function. + // Example: require('fs').promises.readdir('.') returns a native Promise. + const script = ` + try { + const fs = require('fs'); + const p = fs.promises.readdir('.'); + // Before awaiting, inspect the promise's constructor chain + const PromiseCtor = p.constructor; + if (!PromiseCtor) return { escaped: false, reason: 'constructor is null/falsy' }; + const F = PromiseCtor.constructor; + if (!F) return { escaped: false, reason: 'Promise.constructor is null/falsy' }; + const proc = F('return process')(); + return { escaped: true, pid: proc?.pid }; + } catch (e) { + return { escaped: false, error: String(e) }; + } + `; + + const result = await ScriptInstruction.run(script, {}, { logger }); + + expect(result.status).toBe(JOB_STATUS.RESOLVED); + expect(result.result.escaped).toBe(false); + }); + + it('Promise-returning module functions should still work after sanitization (fs.promises.readdir)', async () => { + // Functional regression: async module APIs must still return usable thenables. + // Within the script, module-returned arrays are null-proto objects for security + // (raw host Arrays expose __proto__.constructor → host Function → RCE), so: + // - for...of and spread work via the safe Symbol.iterator we attach + // - Array.isArray() inside the script returns false + // The *return value* goes through toJsonResult() (JSON round-trip) before + // postMessage, so the caller receives a proper JSON Array and Array.isArray + // on result.result is true. + const script = ` + const fs = require('fs'); + const entries = await fs.promises.readdir('.'); + // Within-script: for...of works via Symbol.iterator + let count = 0; + for (const entry of entries) { + if (typeof entry === 'string') count++; + } + // Spread also works; the produced sandbox Array is returned as the result + const arr = [...entries]; + return arr; + `; + + const result = await ScriptInstruction.run(script, {}, { logger }); + + expect(result.status).toBe(JOB_STATUS.RESOLVED); + // After JSON round-trip at postMessage boundary, result is a proper Array + expect(Array.isArray(result.result)).toBe(true); + expect(result.result.length).toBeGreaterThan(0); + expect(typeof result.result[0]).toBe('string'); + }); + + it('should not allow escape via object returned from a module function (V2c: crypto.createHash)', async () => { + // Reporter's exact bypass: sanitizeForSandbox wraps module functions but + // the object they *return* must also be sanitized, otherwise + // hash.update.constructor === host Function → RCE. + const script = ` + const crypto = require('crypto'); + const h = crypto.createHash('sha256'); + let escaped = false; + try { + const F = h.update.constructor; + if (F) { + const proc = F('return process')(); + escaped = Boolean(proc && proc.pid); + } + } catch (_) {} + return { + escaped, + updateCtorIsNull: h.update.constructor === null, + hashProtoIsNull: Object.getPrototypeOf(h) === null, + }; + `; + + const result = await ScriptInstruction.run(script, {}, { logger }); + + expect(result.status).toBe(JOB_STATUS.RESOLVED); + expect(result.result.escaped).toBe(false); + expect(result.result.updateCtorIsNull).toBe(true); + expect(result.result.hashProtoIsNull).toBe(true); + }); + + it('crypto.createHash should remain functional end-to-end after sanitization', async () => { + const script = ` + const crypto = require('crypto'); + const h = crypto.createHash('sha256'); + h.update('hello'); + return { digest: h.digest('hex') }; + `; + + const result = await ScriptInstruction.run(script, {}, { logger }); + + expect(result.status).toBe(JOB_STATUS.RESOLVED); + // sha256('hello') + expect(result.result.digest).toBe('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'); + }); + + it('deep-prototype methods on module objects remain callable (EventEmitter.on on a Hash)', async () => { + // Regression for the previous MAX_PROTO_DEPTH cap: EventEmitter.on lives + // several prototypes up from a Hash instance. With the cap removed, the + // sandbox must still see it as a hardened function. + const script = ` + const crypto = require('crypto'); + const h = crypto.createHash('sha256'); + return { + hasOn: typeof h.on === 'function', + onCtorIsNull: typeof h.on === 'function' ? h.on.constructor === null : null, + }; + `; + + const result = await ScriptInstruction.run(script, {}, { logger }); + + expect(result.status).toBe(JOB_STATUS.RESOLVED); + expect(result.result.hasOn).toBe(true); + expect(result.result.onCtorIsNull).toBe(true); + }); + + it('should not allow escape via require() called with non-string argument (V3: arg-validation error)', async () => { + // Path.isAbsolute / m.split would previously throw a raw host TypeError + // when m is not a string, exposing e.constructor.constructor === host Function. + const script = ` + const cases = [null, undefined, 42, {}, []]; + const results = []; + for (const c of cases) { + try { require(c); results.push({ threw: false }); } + catch (e) { results.push({ threw: true, ctorIsNull: e.constructor === null }); } + } + return results; + `; + + const result = await ScriptInstruction.run(script, {}, { logger }); + + expect(result.status).toBe(JOB_STATUS.RESOLVED); + for (const r of result.result) { + expect(r.threw).toBe(true); + expect(r.ctorIsNull).toBe(true); + } + }); + + it('should not allow escape via require() of missing submodule under a whitelisted package (V3: MODULE_NOT_FOUND)', async () => { + // WORKFLOW_SCRIPT_MODULES=fs — mainName='fs' is whitelisted, but the + // subpath does not exist. Node's MODULE_NOT_FOUND must not reach the + // sandbox as a raw host Error. + const script = ` + try { + require('fs/__definitely_not_a_real_subpath__'); + return { escaped: false, reason: 'no error thrown' }; + } catch (e) { + try { + const F = e.constructor.constructor; + const proc = F('return process')(); + return { escaped: true, pid: proc?.pid }; + } catch (_) { + return { escaped: false, ctorIsNull: e.constructor === null }; + } + } + `; + + const result = await ScriptInstruction.run(script, {}, { logger }); + + expect(result.status).toBe(JOB_STATUS.RESOLVED); + expect(result.result.escaped).toBe(false); + expect(result.result.ctorIsNull).toBe(true); + }); });