mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-24 16:02:20 +08:00
Merge branch 'main' into next
This commit is contained in:
@@ -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
|
||||
})
|
||||
|
||||
+2
-333
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user