Merge branch 'main' into next

This commit is contained in:
nocobase[bot]
2026-04-21 01:57:22 +00:00
3 changed files with 42 additions and 669 deletions
@@ -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=
@@ -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
})
@@ -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);
});
});