// server/lib/code-worker.ts — Isolated code execution worker
// Runs as a child process via fork(). Receives code + context via IPC,
// executes in a fresh vm context, returns result. Crash here cannot take down the server.
import { runInNewContext } from "node:vm";
import { randomUUID } from "node:crypto";
interface WorkerMessage {
type: "execute";
code: string;
context: {
steps: Record<string, unknown>;
trigger: Record<string, unknown>;
input?: unknown;
};
timeoutMs: number;
}
interface WorkerResult {
type: "result";
success: boolean;
output?: unknown;
logs?: string[];
error?: string;
}
/**
* Create a safe wrapper function that cannot be used to reach the host Function constructor.
* Wraps a host function in a proxy whose prototype chain is severed — .constructor returns
* undefined instead of the host Function constructor.
*/
function safeFunction<T extends (...args: any[]) => any>(fn: T): T {
// Create a wrapper function whose .constructor and .prototype are severed.
// We can't make a callable null-prototype object directly, so we override them on a regular function.
const safe = function (this: unknown, ...args: unknown[]) {
return fn.apply(this, args);
};
Object.defineProperty(safe, "constructor", { value: undefined, writable: false, configurable: false });
Object.defineProperty(safe, "prototype", { value: undefined, writable: false, configurable: false });
// Freeze to prevent re-assignment of constructor
Object.freeze(safe);
return safe as unknown as T;
}
/**
* Create a safe namespace object (like Math, JSON, Buffer) whose properties
* cannot reach the host Function constructor through any prototype chain.
* All function-valued properties are wrapped via safeFunction().
*/
function safeNamespace(source: Record<string, unknown>, keys: string[]): Readonly<Record<string, unknown>> {
const ns = Object.create(null) as Record<string, unknown>;
for (const key of keys) {
const val = source[key];
if (typeof val === "function") {
ns[key] = safeFunction(val as (...args: any[]) => any);
} else {
ns[key] = val;
}
}
return Object.freeze(ns);
}
// Only run when forked as a child process
if (process.send) {
process.on("message", (msg: WorkerMessage) => {
if (msg.type !== "execute") return;
const logs: string[] = [];
// Validate code for dangerous patterns (defense-in-depth — sandbox isolation is primary defense)
const dangerousPatterns = [
/constructor\s*\[/i,
/constructor\s*\(/i,
/\.constructor/i,
/__proto__/i,
/prototype\s*\[/i,
/\bprocess\b/,
/\brequire\b/,
/\bimport\b/,
/\bglobalThis\b/,
/\bglobal\b/,
/\bFunction\b/,
/\beval\b/,
/\bReflect\b/,
/\bProxy\b/,
/\bSymbol\b/,
/\bWeakRef\b/,
];
for (const pattern of dangerousPatterns) {
if (pattern.test(msg.code)) {
const result: WorkerResult = {
type: "result",
success: false,
error: `Code contains blocked pattern: ${pattern.source}`,
};
process.send!(result);
return;
}
}
// Build hardened sandbox — NO host constructors or direct function references.
// vm.runInNewContext provides its own Array, Object, String, Number, Boolean in the
// sandbox context. We do NOT pass host realm constructors, which would allow
// escaping via .constructor.constructor('return process')().
const sandbox = Object.create(null) as Record<string, unknown>;
sandbox.steps = JSON.parse(JSON.stringify(msg.context.steps));
sandbox.trigger = JSON.parse(JSON.stringify(msg.context.trigger));
sandbox.input = msg.context.input != null ? JSON.parse(JSON.stringify(msg.context.input)) : undefined;
sandbox.output = undefined;
// console — safe: null-prototype object with wrapped log function
const logFn = safeFunction((...args: unknown[]) => logs.push(args.map(String).join(" ")));
const consoleObj = Object.create(null) as Record<string, unknown>;
consoleObj.log = logFn;
sandbox.console = Object.freeze(consoleObj);
// JSON — safe namespace with only parse/stringify
sandbox.JSON = safeNamespace(JSON as unknown as Record<string, unknown>, ["parse", "stringify"]);
// Math — safe namespace (all methods wrapped, numeric constants preserved)
const mathKeys = [
"abs", "ceil", "floor", "round", "max", "min", "pow", "sqrt", "log", "log2", "log10",
"random", "sign", "trunc", "cbrt", "hypot", "clz32", "imul", "fround",
"sin", "cos", "tan", "asin", "acos", "atan", "atan2", "sinh", "cosh", "tanh",
"PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2",
];
sandbox.Math = safeNamespace(Math as unknown as Record<string, unknown>, mathKeys);
// Date — only expose Date.now() as a safe function, not the Date constructor itself.
// The Date constructor is a host function whose .constructor leads to Function.
const dateNs = Object.create(null) as Record<string, unknown>;
dateNs.now = safeFunction(Date.now);
sandbox.Date = Object.freeze(dateNs);
// Utility functions — each wrapped to sever .constructor chain
sandbox.parseInt = safeFunction(parseInt);
sandbox.parseFloat = safeFunction(parseFloat);
sandbox.isNaN = safeFunction(isNaN);
sandbox.isFinite = safeFunction(isFinite);
sandbox.encodeURIComponent = safeFunction(encodeURIComponent);
sandbox.decodeURIComponent = safeFunction(decodeURIComponent);
// URL — safe namespace with only parse method (no constructor exposure)
const urlNs = Object.create(null) as Record<string, unknown>;
urlNs.parse = safeFunction((input: string) => {
try {
const u = new URL(input);
return { href: u.href, protocol: u.protocol, host: u.host, hostname: u.hostname,
port: u.port, pathname: u.pathname, search: u.search, hash: u.hash,
origin: u.origin, searchParams: Object.fromEntries(u.searchParams) };
} catch { return null; }
});
urlNs.format = safeFunction((parts: Record<string, string>) => {
try { return new URL(`${parts.protocol || "https:"}//${parts.host || parts.hostname || ""}${parts.pathname || ""}${parts.search || ""}`).href; }
catch { return null; }
});
sandbox.URL = Object.freeze(urlNs);
// Buffer — safe namespace with wrapped from/alloc
const bufferNs = Object.create(null) as Record<string, unknown>;
bufferNs.from = safeFunction((...args: unknown[]) => Buffer.from(args[0] as any, args[1] as any));
bufferNs.alloc = safeFunction((size: number) => Buffer.alloc(size));
sandbox.Buffer = Object.freeze(bufferNs);
// crypto — safe namespace with wrapped randomUUID
const cryptoNs = Object.create(null) as Record<string, unknown>;
cryptoNs.randomUUID = safeFunction(randomUUID);
sandbox.crypto = Object.freeze(cryptoNs);
// DO NOT expose host constructors: Array, Object, String, Number, Boolean.
// vm.runInNewContext creates its own versions of these in the sandbox context.
// Passing host constructors would allow: [].constructor.constructor('return process')()
// Block dangerous globals explicitly
sandbox.process = undefined;
sandbox.require = undefined;
sandbox.global = undefined;
sandbox.globalThis = undefined;
sandbox.Function = undefined;
sandbox.eval = undefined;
sandbox.setTimeout = undefined;
sandbox.setInterval = undefined;
sandbox.Reflect = undefined;
sandbox.Proxy = undefined;
sandbox.Symbol = undefined;
sandbox.WeakRef = undefined;
try {
const result = runInNewContext(msg.code, sandbox, {
timeout: msg.timeoutMs,
filename: "workflow-code-step",
breakOnSigint: true,
microtaskMode: "afterEvaluate",
});
const workerResult: WorkerResult = {
type: "result",
success: true,
output: { result: sandbox.output ?? result, logs },
};
process.send!(workerResult);
} catch (err: any) {
const workerResult: WorkerResult = {
type: "result",
success: false,
error: err.code === "ERR_SCRIPT_EXECUTION_TIMEOUT"
? `Code execution timed out after ${msg.timeoutMs}ms`
: `Code error: ${err.message}`,
logs,
};
process.send!(workerResult);
}
});
// Signal ready
process.send({ type: "ready" });
}