diff --git a/packages/plugins/@nocobase/plugin-workflow-javascript/.env.example b/packages/plugins/@nocobase/plugin-workflow-javascript/.env.example deleted file mode 100644 index b8ef5607835..00000000000 --- a/packages/plugins/@nocobase/plugin-workflow-javascript/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# 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 140568da20b..fbd4cc36b1a 100644 --- a/packages/plugins/@nocobase/plugin-workflow-javascript/src/server/Vm.js +++ b/packages/plugins/@nocobase/plugin-workflow-javascript/src/server/Vm.js @@ -13,306 +13,44 @@ 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 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. + * Sever the prototype chain of a host-realm function so that + * Object.getPrototypeOf(fn).constructor cannot reach the host Function constructor. */ -function harden(v) { - Object.setPrototypeOf(v, null); - Object.defineProperty(v, 'constructor', { +function hardenFunction(fn) { + Object.setPrototypeOf(fn, null); + Object.defineProperty(fn, 'constructor', { value: null, writable: false, enumerable: false, configurable: false, }); - return v; + return fn; } -/** - * 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); +hardenFunction(customRequire); /** * Create a safe console proxy that only exposes whitelisted logging methods. @@ -347,7 +85,8 @@ function createSafeConsole(originalConsole) { for (const key of allowedMethods) { if (typeof originalConsole[key] === 'function') { - const bound = harden(originalConsole[key].bind(originalConsole)); + const bound = originalConsole[key].bind(originalConsole); + hardenFunction(bound); Object.defineProperty(safe, key, { value: bound, writable: false, @@ -357,7 +96,14 @@ function createSafeConsole(originalConsole) { } } - return Object.freeze(harden(safe)); + Object.defineProperty(safe, 'constructor', { + value: null, + writable: false, + enumerable: false, + configurable: false, + }); + + return Object.freeze(safe); } async function main() { @@ -387,37 +133,10 @@ 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: toJsonResult(result) }); + parentPort.postMessage({ type: 'result', 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 bfa0c44647a..81503e35ffd 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,8 +7,6 @@ * 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'; @@ -145,9 +143,8 @@ 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. - // fs is included so that Promise-returning module APIs can be tested. - process.env.WORKFLOW_SCRIPT_MODULES = 'path,crypto,fs'; + // Setting WORKFLOW_SCRIPT_MODULES triggers the node vm engine + process.env.WORKFLOW_SCRIPT_MODULES = 'path,crypto'; transport = new CacheTransport(); logger = winston.createLogger({ transports: [transport], @@ -331,136 +328,6 @@ 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 = ` @@ -531,202 +398,4 @@ 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); - }); });