diff --git a/dist/components/proxy-middleware/rule_action_processor/processors/modify_request_processor.js b/dist/components/proxy-middleware/rule_action_processor/processors/modify_request_processor.js index a75d287..6d0b97f 100644 --- a/dist/components/proxy-middleware/rule_action_processor/processors/modify_request_processor.js +++ b/dist/components/proxy-middleware/rule_action_processor/processors/modify_request_processor.js @@ -25,16 +25,9 @@ const modify_request = (ctx, new_req) => { ctx.rq_request_body = new_req; }; const modify_request_using_code = async (action, ctx) => { - let userFunction = null; - try { - userFunction = (0, utils_2.getFunctionFromString)(action.request); - } - catch (error) { - // User has provided an invalid function - return modify_request(ctx, "Can't parse Requestly function. Please recheck. Error Code 7201. Actual Error: " + - error.message); - } - if (!userFunction || typeof userFunction !== "function") { + // RQ-2426: validate the function source parses (in an isolate, without + // executing it) before running it sandboxed. + if (!(await (0, utils_2.isValidFunctionString)(action.request))) { // User has provided an invalid function return modify_request(ctx, "Can't parse Requestly function. Please recheck. Error Code 944."); } @@ -58,7 +51,7 @@ const modify_request_using_code = async (action, ctx) => { catch (_a) { /*Do nothing -- could not parse body as JSON */ } - finalRequest = await (0, utils_2.executeUserFunction)(ctx, userFunction, args); + finalRequest = await (0, utils_2.executeUserFunction)(ctx, action.request, args); if (finalRequest && typeof finalRequest === "string") { return modify_request(ctx, finalRequest); } diff --git a/dist/components/proxy-middleware/rule_action_processor/processors/modify_response_processor.js b/dist/components/proxy-middleware/rule_action_processor/processors/modify_response_processor.js index 2c54af6..f98ae47 100644 --- a/dist/components/proxy-middleware/rule_action_processor/processors/modify_response_processor.js +++ b/dist/components/proxy-middleware/rule_action_processor/processors/modify_response_processor.js @@ -103,16 +103,9 @@ const modify_response_using_local = (action, ctx) => { }; const modify_response_using_code = async (action, ctx) => { var _a, _b, _c, _d; - let userFunction = null; - try { - userFunction = (0, utils_2.getFunctionFromString)(action.response); - } - catch (error) { - // User has provided an invalid function - return modify_response(ctx, "Can't parse Requestly function. Please recheck. Error Code 7201. Actual Error: " + - error.message); - } - if (!userFunction || typeof userFunction !== "function") { + // RQ-2426: validate the function source parses (in an isolate, without + // executing it) before running it sandboxed. + if (!(await (0, utils_2.isValidFunctionString)(action.response))) { // User has provided an invalid function return modify_response(ctx, "Can't parse Requestly function. Please recheck. Error Code 944."); } diff --git a/dist/utils/index.d.ts b/dist/utils/index.d.ts index d385ba0..a70fe15 100644 --- a/dist/utils/index.d.ts +++ b/dist/utils/index.d.ts @@ -1,2 +1,7 @@ -export declare const getFunctionFromString: (functionStringEscaped: any) => any; +/** + * Verify that a rule's code string parses as a function WITHOUT executing it in + * the host. Compiling in a throwaway isolate proves it parses; no host globals + * are exposed and nothing runs. Returns true if it is valid function source. + */ +export declare const isValidFunctionString: (functionStringEscaped: string) => Promise; export declare function executeUserFunction(ctx: any, functionString: string, args: any): Promise; diff --git a/dist/utils/index.js b/dist/utils/index.js index 892f143..90e05b3 100644 --- a/dist/utils/index.js +++ b/dist/utils/index.js @@ -3,44 +3,215 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.getFunctionFromString = void 0; +exports.isValidFunctionString = void 0; exports.executeUserFunction = executeUserFunction; -const util_1 = require("util"); -const capture_console_logs_1 = __importDefault(require("capture-console-logs")); +const isolated_vm_1 = __importDefault(require("isolated-vm")); const state_1 = __importDefault(require("../components/proxy-middleware/middlewares/state")); -// Only used for verification now. For execution, we regenerate the function in executeUserFunction with the sharedState -const getFunctionFromString = function (functionStringEscaped) { - return new Function(`return ${functionStringEscaped}`)(); +/** + * RQ-2426: rule-supplied "code" rules (Modify Request/Response) used to be run + * with `new Function(...)` directly in the proxy's Node.js process — full access + * to require/process/fs/child_process. Since code rules travel between users + * (shared lists, imports, team sync), that was a supply-chain RCE primitive. + * + * Rule code now runs inside an `isolated-vm` V8 isolate with NO host realm + * access (no require/process/fs/network globals), a hard wall-clock timeout, and + * a memory cap. Only copied data (args, sharedState) and a narrow set of safe + * shims (console, atob/btoa) are exposed. The function contract is unchanged: + * `userFn(args)` returns a string (objects are JSON-stringified), promises are + * awaited, console output is captured, and $sharedState is read/written back. + */ +const EXEC_TIMEOUT_MS = 5000; // hard wall-clock cap; stops infinite loops +const MEMORY_LIMIT_MB = 128; // per-execution isolate memory ceiling +const VALIDATE_MEMORY_LIMIT_MB = 16; +const FETCH_TIMEOUT_MS = 10000; // per fetch() call from inside a rule +const MAX_FETCH_BODY_BYTES = 10 * 1024 * 1024; // cap response copied back into the isolate +/** + * Host side of the sandbox `fetch`. Runs in the proxy process (the only place + * with network), but is reachable from the isolate ONLY through this narrow, + * explicitly-injected reference — the isolate still has no direct network/host + * access. Returns a JSON string (a transferable primitive) describing the + * response; the in-isolate shim rebuilds a Response-like object from it. + * + * Note: this preserves the prior capability of code rules to call out to APIs. + * It does not add SSRF restrictions (outbound requests are the feature) but it + * bounds time and response size, and only forwards method/headers/body. + */ +async function hostFetch(reqJson) { + var _a, _b; + let req; + try { + req = JSON.parse(reqJson); + } + catch (_c) { + return JSON.stringify({ __rqError: "Invalid fetch arguments" }); + } + const url = String((_a = req === null || req === void 0 ? void 0 : req.url) !== null && _a !== void 0 ? _a : ""); + const rawOpts = (_b = req === null || req === void 0 ? void 0 : req.opts) !== null && _b !== void 0 ? _b : {}; + const init = {}; + if (typeof rawOpts.method === "string") + init.method = rawOpts.method; + if (rawOpts.headers && typeof rawOpts.headers === "object") { + init.headers = {}; + for (const k of Object.keys(rawOpts.headers)) { + init.headers[k] = String(rawOpts.headers[k]); + } + } + if (rawOpts.body !== undefined && rawOpts.body !== null) { + init.body = + typeof rawOpts.body === "string" + ? rawOpts.body + : JSON.stringify(rawOpts.body); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + init.signal = controller.signal; + const resp = await fetch(url, init); + let bodyText = await resp.text(); + if (bodyText.length > MAX_FETCH_BODY_BYTES) { + bodyText = bodyText.slice(0, MAX_FETCH_BODY_BYTES); + } + const headers = {}; + resp.headers.forEach((v, k) => { + headers[k.toLowerCase()] = v; + }); + return JSON.stringify({ + status: resp.status, + statusText: resp.statusText, + ok: resp.ok, + url: resp.url, + headers, + bodyText, + }); + } + catch (e) { + return JSON.stringify({ __rqError: String((e && e.message) || e) }); + } + finally { + clearTimeout(timer); + } +} +/** + * Verify that a rule's code string parses as a function WITHOUT executing it in + * the host. Compiling in a throwaway isolate proves it parses; no host globals + * are exposed and nothing runs. Returns true if it is valid function source. + */ +const isValidFunctionString = async function (functionStringEscaped) { + const isolate = new isolated_vm_1.default.Isolate({ memoryLimit: VALIDATE_MEMORY_LIMIT_MB }); + try { + await isolate.compileScript(`(${functionStringEscaped})`); + return true; + } + catch (_a) { + return false; + } + finally { + isolate.dispose(); + } }; -exports.getFunctionFromString = getFunctionFromString; -/* Expects that the functionString has already been validated to be representing a proper function */ +exports.isValidFunctionString = isValidFunctionString; async function executeUserFunction(ctx, functionString, args) { - const generateFunctionWithSharedState = function (functionStringEscaped) { - const SHARED_STATE_VAR_NAME = "$sharedState"; - const sharedState = state_1.default.getInstance().getSharedStateCopy(); - return new Function(`${SHARED_STATE_VAR_NAME}`, `return { func: ${functionStringEscaped}, updatedSharedState: ${SHARED_STATE_VAR_NAME}}`)(sharedState); - }; - const { func: generatedFunction, updatedSharedState } = generateFunctionWithSharedState(functionString); - const consoleCapture = new capture_console_logs_1.default(); - consoleCapture.start(true); - let finalResponse = generatedFunction(args); - if (util_1.types.isPromise(finalResponse)) { - finalResponse = await finalResponse; - } - consoleCapture.stop(); - const consoleLogs = consoleCapture.getCaptures(); - ctx.rq.consoleLogs.push(...consoleLogs); - /** - * If we use GlobalState.getSharedStateRef instead of GlobalState.getSharedStateCopy - * then this update is completely unnecessary. - * Because then the function gets a reference to the global states, - * and any changes made inside the userFunction will directly be reflected there. - * - * But we are using it here to make the data flow obvious as we read this code. - */ - state_1.default.getInstance().setSharedState(updatedSharedState); - if (typeof finalResponse === "object") { - finalResponse = JSON.stringify(finalResponse); - } - return finalResponse; + var _a, _b, _c; + const isolate = new isolated_vm_1.default.Isolate({ memoryLimit: MEMORY_LIMIT_MB }); + const collectedLogs = []; + try { + const context = await isolate.createContext(); + const jail = context.global; + // Copy in only plain data. JSON round-trip guarantees the values are + // structured-cloneable and strips anything non-serializable. + const safeArgs = JSON.parse(JSON.stringify(args !== null && args !== void 0 ? args : {})); + const sharedState = JSON.parse(JSON.stringify((_a = state_1.default.getInstance().getSharedStateCopy()) !== null && _a !== void 0 ? _a : {})); + await jail.set("global", jail.derefInto()); + await jail.set("__args", new isolated_vm_1.default.ExternalCopy(safeArgs).copyInto()); + await jail.set("__sharedState", new isolated_vm_1.default.ExternalCopy(sharedState).copyInto()); + // console -> host capture (matches capture-console-logs shape: {type, args}). + await jail.set("__log", new isolated_vm_1.default.Reference((payloadJson) => { + try { + collectedLogs.push(JSON.parse(payloadJson)); + } + catch (_a) { + /* ignore malformed log payloads */ + } + })); + // base64 helpers — the isolate has no Buffer/atob/btoa; bridge to host Buffer. + await jail.set("__btoa", new isolated_vm_1.default.Reference((s) => Buffer.from(String(s), "binary").toString("base64"))); + await jail.set("__atob", new isolated_vm_1.default.Reference((s) => Buffer.from(String(s), "base64").toString("binary"))); + // Bridged fetch — async host reference (see hostFetch). The isolate calls it + // via applySyncPromise, so rules can `await fetch(...)` as before. + await jail.set("__fetch", new isolated_vm_1.default.Reference(hostFetch)); + // NOTE: the isolate intentionally has no `Buffer`, timers, `require`, + // `process`, or `fs`. Standard ECMAScript built-ins (JSON, Math, Date, RegExp, + // Map/Set, Promise, etc.) are available, plus the explicitly-bridged + // console/atob/btoa/fetch below. See RQ-2426 notes for remaining parity gaps. + const wrapped = ` + const __safe = (x) => { try { JSON.stringify(x); return x; } catch (e) { return String(x); } }; + const __emit = (type, a) => { + try { + __log.applySync(undefined, [JSON.stringify({ type: type, args: Array.prototype.map.call(a, __safe) })]); + } catch (e) { /* never let logging break user code */ } + }; + const console = { + log: function () { __emit("log", arguments); }, + info: function () { __emit("info", arguments); }, + warn: function () { __emit("warn", arguments); }, + error: function () { __emit("error", arguments); }, + debug: function () { __emit("debug", arguments); }, + }; + const btoa = (s) => __btoa.applySync(undefined, [String(s)]); + const atob = (s) => __atob.applySync(undefined, [String(s)]); + const fetch = async (url, opts) => { + const raw = __fetch.applySyncPromise(undefined, [JSON.stringify({ url: String(url), opts: opts || {} })]); + const res = JSON.parse(raw); + if (res.__rqError) { throw new Error(res.__rqError); } + const headers = res.headers || {}; + return { + status: res.status, + statusText: res.statusText, + ok: res.ok, + url: res.url, + headers: { + get: (h) => { const v = headers[String(h).toLowerCase()]; return v === undefined ? null : v; }, + has: (h) => Object.prototype.hasOwnProperty.call(headers, String(h).toLowerCase()), + raw: () => headers, + }, + text: async () => res.bodyText, + json: async () => JSON.parse(res.bodyText), + }; + }; + const $sharedState = __sharedState; + const __userFn = (${functionString}); + Promise.resolve(__userFn(__args)).then(function (r) { + let out; + if (r === undefined || r === null) out = r; + else if (typeof r === "object") out = JSON.stringify(r); + else out = r; + return JSON.stringify({ result: out, sharedState: $sharedState }); + }); + `; + const script = await isolate.compileScript(wrapped); + const resultJson = await script.run(context, { + timeout: EXEC_TIMEOUT_MS, + promise: true, + copy: true, + }); + let finalResponse; + try { + const parsed = JSON.parse(resultJson); + finalResponse = parsed.result; + // Write back any mutations the rule made to $sharedState. + state_1.default.getInstance().setSharedState((_b = parsed.sharedState) !== null && _b !== void 0 ? _b : {}); + } + catch (_d) { + finalResponse = undefined; + } + if (collectedLogs.length && ((_c = ctx === null || ctx === void 0 ? void 0 : ctx.rq) === null || _c === void 0 ? void 0 : _c.consoleLogs)) { + ctx.rq.consoleLogs.push(...collectedLogs); + } + // Objects are already JSON-stringified inside the isolate, so finalResponse + // is a string (or undefined). Mirrors the previous return contract. + return finalResponse; + } + finally { + isolate.dispose(); + } } diff --git a/package-lock.json b/package-lock.json index aa5f0d3..0093bbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "charset": "^1.0.1", "debug": "^4.3.2", "httpsnippet": "^3.0.4", + "isolated-vm": "^6.1.2", "lodash": "^4.17.21", "mime-types": "^2.1.35", "mkdirp": "^0.5.5", @@ -2921,6 +2922,19 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/isolated-vm": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/isolated-vm/-/isolated-vm-6.1.2.tgz", + "integrity": "sha512-GGfsHqtlZiiurZaxB/3kY7LLAXR3sgzDul0fom4cSyBjx6ZbjpTrFWiH3z/nUfLJGJ8PIq9LQmQFiAxu24+I7A==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/issue-parser": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", @@ -3316,6 +3330,17 @@ "node": ">= 6.13.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/nodemon": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.0.tgz", diff --git a/package.json b/package.json index 6e49279..66f854b 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "charset": "^1.0.1", "debug": "^4.3.2", "httpsnippet": "^3.0.4", + "isolated-vm": "^6.1.2", "lodash": "^4.17.21", "mime-types": "^2.1.35", "mkdirp": "^0.5.5", diff --git a/src/components/proxy-middleware/rule_action_processor/processors/modify_request_processor.js b/src/components/proxy-middleware/rule_action_processor/processors/modify_request_processor.js index d152b2c..4e1ca18 100644 --- a/src/components/proxy-middleware/rule_action_processor/processors/modify_request_processor.js +++ b/src/components/proxy-middleware/rule_action_processor/processors/modify_request_processor.js @@ -5,7 +5,7 @@ import { } from "@requestly/requestly-core"; import { get_request_url } from "../../helpers/proxy_ctx_helper"; import { build_action_processor_response } from "../utils"; -import { executeUserFunction, getFunctionFromString } from "../../../../utils"; +import { executeUserFunction, isValidFunctionString } from "../../../../utils"; const process_modify_request_action = (action, ctx) => { const allowed_handlers = [PROXY_HANDLER_TYPE.ON_REQUEST_END]; @@ -31,19 +31,9 @@ const modify_request = (ctx, new_req) => { }; const modify_request_using_code = async (action, ctx) => { - let userFunction = null; - try { - userFunction = getFunctionFromString(action.request); - } catch (error) { - // User has provided an invalid function - return modify_request( - ctx, - "Can't parse Requestly function. Please recheck. Error Code 7201. Actual Error: " + - error.message - ); - } - - if (!userFunction || typeof userFunction !== "function") { + // RQ-2426: validate the function source parses (in an isolate, without + // executing it) before running it sandboxed. + if (!(await isValidFunctionString(action.request))) { // User has provided an invalid function return modify_request( ctx, @@ -73,7 +63,7 @@ const modify_request_using_code = async (action, ctx) => { /*Do nothing -- could not parse body as JSON */ } - finalRequest = await executeUserFunction(ctx, userFunction, args) + finalRequest = await executeUserFunction(ctx, action.request, args) if (finalRequest && typeof finalRequest === "string") { return modify_request(ctx, finalRequest); diff --git a/src/components/proxy-middleware/rule_action_processor/processors/modify_response_processor.js b/src/components/proxy-middleware/rule_action_processor/processors/modify_response_processor.js index 223a0c4..ce26a26 100644 --- a/src/components/proxy-middleware/rule_action_processor/processors/modify_response_processor.js +++ b/src/components/proxy-middleware/rule_action_processor/processors/modify_response_processor.js @@ -6,7 +6,7 @@ import { import { getResponseContentTypeHeader, getResponseHeaders, get_request_url } from "../../helpers/proxy_ctx_helper"; import { build_action_processor_response, build_post_process_data, get_file_contents } from "../utils"; import { getContentType, parseJsonBody } from "../../helpers/http_helpers"; -import { executeUserFunction, getFunctionFromString } from "../../../../utils"; +import { executeUserFunction, isValidFunctionString } from "../../../../utils"; import { RQ_INTERCEPTED_CONTENT_TYPES_REGEX } from "../../constants"; const process_modify_response_action = async (action, ctx) => { @@ -123,19 +123,9 @@ const modify_response_using_local = (action, ctx) => { }; const modify_response_using_code = async (action, ctx) => { - let userFunction = null; - try { - userFunction = getFunctionFromString(action.response); - } catch (error) { - // User has provided an invalid function - return modify_response( - ctx, - "Can't parse Requestly function. Please recheck. Error Code 7201. Actual Error: " + - error.message - ); - } - - if (!userFunction || typeof userFunction !== "function") { + // RQ-2426: validate the function source parses (in an isolate, without + // executing it) before running it sandboxed. + if (!(await isValidFunctionString(action.response))) { // User has provided an invalid function return modify_response( ctx, diff --git a/src/utils/index.ts b/src/utils/index.ts index 7b5af9b..87e92a8 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,54 +1,234 @@ -import { types } from "util"; -import ConsoleCapture from "capture-console-logs"; +import ivm from "isolated-vm"; import GlobalStateProvider from "../components/proxy-middleware/middlewares/state"; -// Only used for verification now. For execution, we regenerate the function in executeUserFunction with the sharedState -export const getFunctionFromString = function (functionStringEscaped) { - return new Function(`return ${functionStringEscaped}`)(); +/** + * RQ-2426: rule-supplied "code" rules (Modify Request/Response) used to be run + * with `new Function(...)` directly in the proxy's Node.js process — full access + * to require/process/fs/child_process. Since code rules travel between users + * (shared lists, imports, team sync), that was a supply-chain RCE primitive. + * + * Rule code now runs inside an `isolated-vm` V8 isolate with NO host realm + * access (no require/process/fs/network globals), a hard wall-clock timeout, and + * a memory cap. Only copied data (args, sharedState) and a narrow set of safe + * shims (console, atob/btoa) are exposed. The function contract is unchanged: + * `userFn(args)` returns a string (objects are JSON-stringified), promises are + * awaited, console output is captured, and $sharedState is read/written back. + */ + +const EXEC_TIMEOUT_MS = 5000; // hard wall-clock cap; stops infinite loops +const MEMORY_LIMIT_MB = 128; // per-execution isolate memory ceiling +const VALIDATE_MEMORY_LIMIT_MB = 16; +const FETCH_TIMEOUT_MS = 10000; // per fetch() call from inside a rule +const MAX_FETCH_BODY_BYTES = 10 * 1024 * 1024; // cap response copied back into the isolate + +/** + * Host side of the sandbox `fetch`. Runs in the proxy process (the only place + * with network), but is reachable from the isolate ONLY through this narrow, + * explicitly-injected reference — the isolate still has no direct network/host + * access. Returns a JSON string (a transferable primitive) describing the + * response; the in-isolate shim rebuilds a Response-like object from it. + * + * Note: this preserves the prior capability of code rules to call out to APIs. + * It does not add SSRF restrictions (outbound requests are the feature) but it + * bounds time and response size, and only forwards method/headers/body. + */ +async function hostFetch(reqJson: string): Promise { + let req: any; + try { + req = JSON.parse(reqJson); + } catch { + return JSON.stringify({ __rqError: "Invalid fetch arguments" }); + } + const url = String(req?.url ?? ""); + const rawOpts = req?.opts ?? {}; + const init: any = {}; + if (typeof rawOpts.method === "string") init.method = rawOpts.method; + if (rawOpts.headers && typeof rawOpts.headers === "object") { + init.headers = {}; + for (const k of Object.keys(rawOpts.headers)) { + init.headers[k] = String(rawOpts.headers[k]); + } + } + if (rawOpts.body !== undefined && rawOpts.body !== null) { + init.body = + typeof rawOpts.body === "string" + ? rawOpts.body + : JSON.stringify(rawOpts.body); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + init.signal = controller.signal; + const resp = await fetch(url, init); + let bodyText = await resp.text(); + if (bodyText.length > MAX_FETCH_BODY_BYTES) { + bodyText = bodyText.slice(0, MAX_FETCH_BODY_BYTES); + } + const headers: Record = {}; + resp.headers.forEach((v: string, k: string) => { + headers[k.toLowerCase()] = v; + }); + return JSON.stringify({ + status: resp.status, + statusText: resp.statusText, + ok: resp.ok, + url: resp.url, + headers, + bodyText, + }); + } catch (e: any) { + return JSON.stringify({ __rqError: String((e && e.message) || e) }); + } finally { + clearTimeout(timer); + } +} + +/** + * Verify that a rule's code string parses as a function WITHOUT executing it in + * the host. Compiling in a throwaway isolate proves it parses; no host globals + * are exposed and nothing runs. Returns true if it is valid function source. + */ +export const isValidFunctionString = async function ( + functionStringEscaped: string +): Promise { + const isolate = new ivm.Isolate({ memoryLimit: VALIDATE_MEMORY_LIMIT_MB }); + try { + await isolate.compileScript(`(${functionStringEscaped})`); + return true; + } catch { + return false; + } finally { + isolate.dispose(); + } }; +export async function executeUserFunction( + ctx: any, + functionString: string, + args: any +): Promise { + const isolate = new ivm.Isolate({ memoryLimit: MEMORY_LIMIT_MB }); + const collectedLogs: any[] = []; -/* Expects that the functionString has already been validated to be representing a proper function */ -export async function executeUserFunction(ctx, functionString: string, args) { + try { + const context = await isolate.createContext(); + const jail = context.global; - const generateFunctionWithSharedState = function (functionStringEscaped) { + // Copy in only plain data. JSON round-trip guarantees the values are + // structured-cloneable and strips anything non-serializable. + const safeArgs = JSON.parse(JSON.stringify(args ?? {})); + const sharedState = JSON.parse( + JSON.stringify(GlobalStateProvider.getInstance().getSharedStateCopy() ?? {}) + ); - const SHARED_STATE_VAR_NAME = "$sharedState"; - - const sharedState = GlobalStateProvider.getInstance().getSharedStateCopy(); - - return new Function(`${SHARED_STATE_VAR_NAME}`, `return { func: ${functionStringEscaped}, updatedSharedState: ${SHARED_STATE_VAR_NAME}}`)(sharedState); - }; + await jail.set("global", jail.derefInto()); + await jail.set("__args", new ivm.ExternalCopy(safeArgs).copyInto()); + await jail.set("__sharedState", new ivm.ExternalCopy(sharedState).copyInto()); - const {func: generatedFunction, updatedSharedState} = generateFunctionWithSharedState(functionString); - - const consoleCapture = new ConsoleCapture() - consoleCapture.start(true) + // console -> host capture (matches capture-console-logs shape: {type, args}). + await jail.set( + "__log", + new ivm.Reference((payloadJson: string) => { + try { + collectedLogs.push(JSON.parse(payloadJson)); + } catch { + /* ignore malformed log payloads */ + } + }) + ); + // base64 helpers — the isolate has no Buffer/atob/btoa; bridge to host Buffer. + await jail.set( + "__btoa", + new ivm.Reference((s: string) => + Buffer.from(String(s), "binary").toString("base64") + ) + ); + await jail.set( + "__atob", + new ivm.Reference((s: string) => + Buffer.from(String(s), "base64").toString("binary") + ) + ); + // Bridged fetch — async host reference (see hostFetch). The isolate calls it + // via applySyncPromise, so rules can `await fetch(...)` as before. + await jail.set("__fetch", new ivm.Reference(hostFetch)); - let finalResponse = generatedFunction(args); + // NOTE: the isolate intentionally has no `Buffer`, timers, `require`, + // `process`, or `fs`. Standard ECMAScript built-ins (JSON, Math, Date, RegExp, + // Map/Set, Promise, etc.) are available, plus the explicitly-bridged + // console/atob/btoa/fetch below. See RQ-2426 notes for remaining parity gaps. + const wrapped = ` + const __safe = (x) => { try { JSON.stringify(x); return x; } catch (e) { return String(x); } }; + const __emit = (type, a) => { + try { + __log.applySync(undefined, [JSON.stringify({ type: type, args: Array.prototype.map.call(a, __safe) })]); + } catch (e) { /* never let logging break user code */ } + }; + const console = { + log: function () { __emit("log", arguments); }, + info: function () { __emit("info", arguments); }, + warn: function () { __emit("warn", arguments); }, + error: function () { __emit("error", arguments); }, + debug: function () { __emit("debug", arguments); }, + }; + const btoa = (s) => __btoa.applySync(undefined, [String(s)]); + const atob = (s) => __atob.applySync(undefined, [String(s)]); + const fetch = async (url, opts) => { + const raw = __fetch.applySyncPromise(undefined, [JSON.stringify({ url: String(url), opts: opts || {} })]); + const res = JSON.parse(raw); + if (res.__rqError) { throw new Error(res.__rqError); } + const headers = res.headers || {}; + return { + status: res.status, + statusText: res.statusText, + ok: res.ok, + url: res.url, + headers: { + get: (h) => { const v = headers[String(h).toLowerCase()]; return v === undefined ? null : v; }, + has: (h) => Object.prototype.hasOwnProperty.call(headers, String(h).toLowerCase()), + raw: () => headers, + }, + text: async () => res.bodyText, + json: async () => JSON.parse(res.bodyText), + }; + }; + const $sharedState = __sharedState; + const __userFn = (${functionString}); + Promise.resolve(__userFn(__args)).then(function (r) { + let out; + if (r === undefined || r === null) out = r; + else if (typeof r === "object") out = JSON.stringify(r); + else out = r; + return JSON.stringify({ result: out, sharedState: $sharedState }); + }); + `; - if (types.isPromise(finalResponse)) { - finalResponse = await finalResponse; + const script = await isolate.compileScript(wrapped); + const resultJson = await script.run(context, { + timeout: EXEC_TIMEOUT_MS, + promise: true, + copy: true, + }); + + let finalResponse: any; + try { + const parsed = JSON.parse(resultJson); + finalResponse = parsed.result; + // Write back any mutations the rule made to $sharedState. + GlobalStateProvider.getInstance().setSharedState(parsed.sharedState ?? {}); + } catch { + finalResponse = undefined; } - consoleCapture.stop() - const consoleLogs = consoleCapture.getCaptures() - - ctx.rq.consoleLogs.push(...consoleLogs) - - /** - * If we use GlobalState.getSharedStateRef instead of GlobalState.getSharedStateCopy - * then this update is completely unnecessary. - * Because then the function gets a reference to the global states, - * and any changes made inside the userFunction will directly be reflected there. - * - * But we are using it here to make the data flow obvious as we read this code. - */ - GlobalStateProvider.getInstance().setSharedState(updatedSharedState); - - if (typeof finalResponse === "object") { - finalResponse = JSON.stringify(finalResponse); - } + if (collectedLogs.length && ctx?.rq?.consoleLogs) { + ctx.rq.consoleLogs.push(...collectedLogs); + } + // Objects are already JSON-stringified inside the isolate, so finalResponse + // is a string (or undefined). Mirrors the previous return contract. return finalResponse; -} \ No newline at end of file + } finally { + isolate.dispose(); + } +}