From 2f3294089195c5fc3b29db93e410b21c76c11ad3 Mon Sep 17 00:00:00 2001 From: dinex-dev Date: Tue, 23 Jun 2026 16:53:40 +0530 Subject: [PATCH] fix(security): RQ-2426 sandbox rule code in QuickJS-WASM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Code"-type Modify Request/Response rules ran rule-supplied JS via `new Function(...)` directly in the proxy's Node process (full require/process/ fs/child_process). Code rules travel between users (shared lists, import/export, team sync), so this was a supply-chain RCE primitive. Rule code now runs inside QuickJS compiled to WebAssembly (quickjs-emscripten, single-file embedded variant). QuickJS is a separate JS engine in the WASM sandbox: no require/process/fs/global, and no prototype path back to the host (constructor-escape blocked). Only injected primitives are reachable. Why QuickJS-WASM (not isolated-vm or worker_threads + vm): - isolated-vm is a native addon with no build for a supported Electron's V8 (6.x too old for V8 13, 7.x needs Node 26). - worker_threads cannot create a Worker in an Electron renderer ("The V8 platform ... does not support creating Workers"), and the proxy runs in the desktop app's background renderer. QuickJS-WASM is pure WASM+JS — builds nowhere natively and runs in the renderer. - src/utils/index.ts: executeUserFunction runs in QuickJS; 5s deadline interrupt, 128MB cap. isValidFunctionString compiles via `new Function` WITHOUT calling it (parse-only, no execution). getFunctionFromString removed. - both Modify Request/Response processors: validate -> pass the source string. - contract preserved: returns a string (objects JSON-stringified), promises awaited, console captured as {type,args}, $sharedState read + written back. - intentional gap: no fetch/Buffer/timers (fetch needs the asyncify variant + async host bridge — a follow-up; QuickJS can do it safely). Verified: sandbox harness 13/13 (Node 24); instantiates + runs + blocks host access inside the Electron 42 renderer; before/after exploit probe flips from RCE/file/env/process access to fully blocked. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../processors/modify_request_processor.js | 15 +- .../processors/modify_response_processor.js | 13 +- dist/utils/index.d.ts | 9 +- dist/utils/index.js | 212 +++++++++++++--- package-lock.json | 82 ++++++- package.json | 4 +- .../processors/modify_request_processor.js | 20 +- .../processors/modify_response_processor.js | 18 +- src/utils/index.ts | 232 +++++++++++++++--- 9 files changed, 475 insertions(+), 130 deletions(-) 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..129d49b 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 (compile-only, no execution) + // before running it in the sandboxed worker. + 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..96a204a 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 (compile-only, no execution) + // before running it in the sandboxed worker. + 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..30ded58 100644 --- a/dist/utils/index.d.ts +++ b/dist/utils/index.d.ts @@ -1,2 +1,9 @@ -export declare const getFunctionFromString: (functionStringEscaped: any) => any; +/** + * Verify a rule's code string parses WITHOUT executing it. Constructing + * `new Function(body)` compiles/parses the body but never runs it (the function + * is never called), so even an IIFE-shaped string cannot execute here. Avoids the + * `vm` module (unsupported in Electron's renderer); the sandboxed execution + * happens inside QuickJS. + */ +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..7a5e448 100644 --- a/dist/utils/index.js +++ b/dist/utils/index.js @@ -3,44 +3,184 @@ 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 quickjs_singlefile_cjs_release_sync_1 = __importDefault(require("@jitl/quickjs-singlefile-cjs-release-sync")); +const quickjs_emscripten_1 = require("quickjs-emscripten"); 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. Code rules travel between users (shared + * lists, import/export, team sync), so that was a supply-chain RCE primitive. + * + * Rule code now runs inside **QuickJS compiled to WebAssembly** (`quickjs-emscripten`). + * QuickJS is a separate JS engine running in the WASM sandbox — it has NO access + * to the host realm (no require/process/fs, no Node/DOM globals, no prototype path + * back to the host). The only things the rule can touch are the values we + * explicitly inject. This is a true isolation boundary. + * + * Why not isolated-vm or worker_threads + vm: + * - isolated-vm is a native addon with no build for a currently-supported + * Electron's V8 (6.x too old for V8 13, 7.x needs Node 26). + * - worker_threads cannot create a Worker in an Electron *renderer* process + * ("The V8 platform used by this instance of Node does not support creating + * Workers"), and the proxy runs in the desktop app's background renderer. + * QuickJS-WASM is pure WASM+JS — it builds nowhere natively and runs in any JS + * environment, including the Electron renderer. + * + * Contract is unchanged: `userFn(args)` returns a string (objects are + * JSON-stringified), promises are awaited, console output is captured into + * `ctx.rq.consoleLogs` as `{type, args}`, and `$sharedState` is read and written + * back. Intentional parity gaps vs the old full-host env: no `fetch`/`Buffer`/ + * timers/`TextEncoder`/`URL`. (`fetch` would need the asyncify QuickJS variant + + * an async host bridge — a follow-up; QuickJS can do it safely, unlike worker+vm.) + */ +const EXEC_TIMEOUT_MS = 5000; +const MEMORY_LIMIT_BYTES = 128 * 1024 * 1024; +const MAX_STACK_BYTES = 2 * 1024 * 1024; +// The WASM module is expensive to instantiate; build it once and reuse across +// executions. A fresh QuickJS *context* is created per execution for isolation. +let modulePromise = null; +function getQuickJSModule() { + if (!modulePromise) { + modulePromise = (0, quickjs_emscripten_1.newQuickJSWASMModuleFromVariant)(quickjs_singlefile_cjs_release_sync_1.default); + } + return modulePromise; +} +// Code that runs INSIDE the QuickJS sandbox to set up the rule environment. +// Built from primitives only (args/$sharedState arrive as JSON strings). console +// captures into __logs; atob/btoa are pure-JS (the sandbox has no Buffer). +// Statements are ';'-separated (no '//' comments) so it concatenates safely. +const SANDBOX_SETUP = [ + "var __logs = [];", + 'var __B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";', + "function btoa(s){ s = String(s); var o = '', i = 0;", + " while (i < s.length) {", + " var r1 = s.charCodeAt(i++), r2 = s.charCodeAt(i++), r3 = s.charCodeAt(i++);", + " var h2 = !isNaN(r2), h3 = !isNaN(r3);", + " var a = r1 & 0xff, b = h2 ? r2 & 0xff : 0, c = h3 ? r3 & 0xff : 0;", + " o += __B64.charAt(a >> 2) + __B64.charAt(((a & 3) << 4) | (b >> 4)) + (h2 ? __B64.charAt(((b & 15) << 2) | (c >> 6)) : '=') + (h3 ? __B64.charAt(c & 63) : '=');", + " } return o; }", + "function atob(s){ s = String(s).replace(/[^A-Za-z0-9+/]/g, ''); var o = '', i = 0;", + " while (i < s.length) {", + " var c1 = s.charAt(i++), c2 = s.charAt(i++), c3 = s.charAt(i++), c4 = s.charAt(i++);", + " var e1 = __B64.indexOf(c1), e2 = __B64.indexOf(c2), e3 = c3 === '' ? -1 : __B64.indexOf(c3), e4 = c4 === '' ? -1 : __B64.indexOf(c4);", + " o += String.fromCharCode((e1 << 2) | (e2 >> 4));", + " if (e3 !== -1) o += String.fromCharCode(((e2 & 15) << 4) | (e3 >> 2));", + " if (e4 !== -1) o += String.fromCharCode(((e3 & 3) << 6) | e4);", + " } return o; }", + "function __safe(x){ try { JSON.stringify(x); return x; } catch (e) { return String(x); } }", + "function __emit(t, a){ try { __logs.push({ type: t, args: Array.prototype.map.call(a, __safe) }); } catch (e) {} }", + "var 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); } };", + "var args = JSON.parse(__argsJson);", + "var $sharedState = JSON.parse(__sharedStateJson);", + "var __OUTPUT = null;", +].join(""); +/** + * Verify a rule's code string parses WITHOUT executing it. Constructing + * `new Function(body)` compiles/parses the body but never runs it (the function + * is never called), so even an IIFE-shaped string cannot execute here. Avoids the + * `vm` module (unsupported in Electron's renderer); the sandboxed execution + * happens inside QuickJS. + */ +const isValidFunctionString = async function (functionStringEscaped) { + try { + // eslint-disable-next-line no-new, no-new-func + new Function(`return (${functionStringEscaped}\n);`); + return true; + } + catch (_a) { + return false; + } }; -exports.getFunctionFromString = getFunctionFromString; -/* Expects that the functionString has already been validated to be representing a proper function */ +exports.isValidFunctionString = isValidFunctionString; +/* Expects that `functionString` has already been validated via 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, _d, _e; + let argsJson = "{}"; + let sharedStateJson = "{}"; + try { + argsJson = JSON.stringify(args !== null && args !== void 0 ? args : {}); + } + catch (_f) { + argsJson = "{}"; + } + try { + sharedStateJson = JSON.stringify((_a = state_1.default.getInstance().getSharedStateCopy()) !== null && _a !== void 0 ? _a : {}); + } + catch (_g) { + sharedStateJson = "{}"; + } + const QuickJS = await getQuickJSModule(); + const vm = QuickJS.newContext(); + try { + vm.runtime.setMemoryLimit(MEMORY_LIMIT_BYTES); + vm.runtime.setMaxStackSize(MAX_STACK_BYTES); + // Hard wall-clock cap — interrupts infinite loops (sync and inside microtasks). + vm.runtime.setInterruptHandler((0, quickjs_emscripten_1.shouldInterruptAfterDeadline)(Date.now() + EXEC_TIMEOUT_MS)); + // Inject inputs as primitive strings (parsed into objects inside the sandbox). + const argsHandle = vm.newString(argsJson); + vm.setProp(vm.global, "__argsJson", argsHandle); + argsHandle.dispose(); + const sharedHandle = vm.newString(sharedStateJson); + vm.setProp(vm.global, "__sharedStateJson", sharedHandle); + sharedHandle.dispose(); + // The user fn is appended after a newline so a trailing '//' comment can't + // swallow the marshaling code. Result (or error) + console + $sharedState are + // serialized into the __OUTPUT global, which we read back on the host side. + const program = SANDBOX_SETUP + + "Promise.resolve((" + + functionString + + "\n)(args)).then(function (r) {" + + " var out;" + + " if (r === undefined || r === null) { out = r; }" + + ' else if (typeof r === "object") { out = JSON.stringify(r); }' + + " else { out = r; }" + + " __OUTPUT = JSON.stringify({ result: out, sharedState: $sharedState, logs: __logs });" + + "}).catch(function (e) {" + + " __OUTPUT = JSON.stringify({ error: String((e && e.message) || e), logs: __logs });" + + "});"; + const evalResult = vm.evalCode(program); + if (evalResult.error) { + // Syntax/throw at the top level (outside the user fn's promise). + evalResult.error.dispose(); + return undefined; + } + // Success variant — dispose the completion value (we read __OUTPUT instead). + evalResult.value.dispose(); + // Resolve the user fn's (possibly async-but-IO-free) promise microtasks. + vm.runtime.executePendingJobs(); + const outHandle = vm.getProp(vm.global, "__OUTPUT"); + const output = vm.dump(outHandle); + outHandle.dispose(); + if (typeof output !== "string") { + // Promise never settled (e.g. unsupported real async) → no modification. + return undefined; + } + let parsed; + try { + parsed = JSON.parse(output); + } + catch (_h) { + return undefined; + } + if (((_b = parsed.logs) === null || _b === void 0 ? void 0 : _b.length) && ((_c = ctx === null || ctx === void 0 ? void 0 : ctx.rq) === null || _c === void 0 ? void 0 : _c.consoleLogs)) { + ctx.rq.consoleLogs.push(...parsed.logs); + } + if (parsed.error) { + if ((_d = ctx === null || ctx === void 0 ? void 0 : ctx.rq) === null || _d === void 0 ? void 0 : _d.consoleLogs) { + ctx.rq.consoleLogs.push({ type: "error", args: [String(parsed.error)] }); + } + return undefined; + } + // Write back any mutations the rule made to $sharedState. + state_1.default.getInstance().setSharedState((_e = parsed.sharedState) !== null && _e !== void 0 ? _e : {}); + // Objects were JSON-stringified inside the sandbox, so result is a string + // (or null/undefined) — mirrors the previous return contract. + return parsed.result; + } + finally { + vm.dispose(); + } } diff --git a/package-lock.json b/package-lock.json index b843677..1404d8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@requestly/requestly-proxy", - "version": "1.5.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@requestly/requestly-proxy", - "version": "1.5.0", + "version": "1.4.0", "license": "ISC", "dependencies": { + "@jitl/quickjs-singlefile-cjs-release-sync": "^0.32.0", "@requestly/requestly-core": "1.1.1", "@sentry/browser": "^8.33.1", "async": "^3.2.5", @@ -21,6 +22,7 @@ "mime-types": "^2.1.35", "mkdirp": "^0.5.5", "node-forge": "^1.3.0", + "quickjs-emscripten": "^0.32.0", "semaphore": "^1.1.0", "ua-parser-js": "^1.0.37", "url": "^0.11.3", @@ -85,6 +87,57 @@ "node": ">=18" } }, + "node_modules/@jitl/quickjs-ffi-types": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", + "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==", + "license": "MIT" + }, + "node_modules/@jitl/quickjs-singlefile-cjs-release-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-singlefile-cjs-release-sync/-/quickjs-singlefile-cjs-release-sync-0.32.0.tgz", + "integrity": "sha512-NjUUcw26PoeJHND6nmflAH8nIvAJvxJ2qkSPi95wfiBqPim80GtcdWommroiWb8hh1/7fVettEwodAsGt2Mrsg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-debug-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz", + "integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-debug-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz", + "integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz", + "integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz", + "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3892,6 +3945,31 @@ } ] }, + "node_modules/quickjs-emscripten": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz", + "integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", + "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-release-sync": "0.32.0", + "quickjs-emscripten-core": "0.32.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quickjs-emscripten-core": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz", + "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", diff --git a/package.json b/package.json index 37eb05f..7a1e7f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@requestly/requestly-proxy", - "version": "1.5.0", + "version": "1.4.0", "description": "Proxy that gives superpowers to all the Requestly clients", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -27,6 +27,7 @@ "author": "", "license": "ISC", "dependencies": { + "@jitl/quickjs-singlefile-cjs-release-sync": "^0.32.0", "@requestly/requestly-core": "1.1.1", "@sentry/browser": "^8.33.1", "async": "^3.2.5", @@ -39,6 +40,7 @@ "mime-types": "^2.1.35", "mkdirp": "^0.5.5", "node-forge": "^1.3.0", + "quickjs-emscripten": "^0.32.0", "semaphore": "^1.1.0", "ua-parser-js": "^1.0.37", "url": "^0.11.3", 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..79cbfcc 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 (compile-only, no execution) + // before running it in the sandboxed worker. + 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..2c33e38 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 (compile-only, no execution) + // before running it in the sandboxed worker. + 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..0b66ec2 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,54 +1,206 @@ -import { types } from "util"; -import ConsoleCapture from "capture-console-logs"; +import variant from "@jitl/quickjs-singlefile-cjs-release-sync"; +import { + newQuickJSWASMModuleFromVariant, + shouldInterruptAfterDeadline, + QuickJSWASMModule, +} from "quickjs-emscripten"; 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. Code rules travel between users (shared + * lists, import/export, team sync), so that was a supply-chain RCE primitive. + * + * Rule code now runs inside **QuickJS compiled to WebAssembly** (`quickjs-emscripten`). + * QuickJS is a separate JS engine running in the WASM sandbox — it has NO access + * to the host realm (no require/process/fs, no Node/DOM globals, no prototype path + * back to the host). The only things the rule can touch are the values we + * explicitly inject. This is a true isolation boundary. + * + * Why not isolated-vm or worker_threads + vm: + * - isolated-vm is a native addon with no build for a currently-supported + * Electron's V8 (6.x too old for V8 13, 7.x needs Node 26). + * - worker_threads cannot create a Worker in an Electron *renderer* process + * ("The V8 platform used by this instance of Node does not support creating + * Workers"), and the proxy runs in the desktop app's background renderer. + * QuickJS-WASM is pure WASM+JS — it builds nowhere natively and runs in any JS + * environment, including the Electron renderer. + * + * Contract is unchanged: `userFn(args)` returns a string (objects are + * JSON-stringified), promises are awaited, console output is captured into + * `ctx.rq.consoleLogs` as `{type, args}`, and `$sharedState` is read and written + * back. Intentional parity gaps vs the old full-host env: no `fetch`/`Buffer`/ + * timers/`TextEncoder`/`URL`. (`fetch` would need the asyncify QuickJS variant + + * an async host bridge — a follow-up; QuickJS can do it safely, unlike worker+vm.) + */ + +const EXEC_TIMEOUT_MS = 5000; +const MEMORY_LIMIT_BYTES = 128 * 1024 * 1024; +const MAX_STACK_BYTES = 2 * 1024 * 1024; + +// The WASM module is expensive to instantiate; build it once and reuse across +// executions. A fresh QuickJS *context* is created per execution for isolation. +let modulePromise: Promise | null = null; +function getQuickJSModule(): Promise { + if (!modulePromise) { + modulePromise = newQuickJSWASMModuleFromVariant(variant as any); + } + return modulePromise; +} + +// Code that runs INSIDE the QuickJS sandbox to set up the rule environment. +// Built from primitives only (args/$sharedState arrive as JSON strings). console +// captures into __logs; atob/btoa are pure-JS (the sandbox has no Buffer). +// Statements are ';'-separated (no '//' comments) so it concatenates safely. +const SANDBOX_SETUP = [ + "var __logs = [];", + 'var __B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";', + "function btoa(s){ s = String(s); var o = '', i = 0;", + " while (i < s.length) {", + " var r1 = s.charCodeAt(i++), r2 = s.charCodeAt(i++), r3 = s.charCodeAt(i++);", + " var h2 = !isNaN(r2), h3 = !isNaN(r3);", + " var a = r1 & 0xff, b = h2 ? r2 & 0xff : 0, c = h3 ? r3 & 0xff : 0;", + " o += __B64.charAt(a >> 2) + __B64.charAt(((a & 3) << 4) | (b >> 4)) + (h2 ? __B64.charAt(((b & 15) << 2) | (c >> 6)) : '=') + (h3 ? __B64.charAt(c & 63) : '=');", + " } return o; }", + "function atob(s){ s = String(s).replace(/[^A-Za-z0-9+/]/g, ''); var o = '', i = 0;", + " while (i < s.length) {", + " var c1 = s.charAt(i++), c2 = s.charAt(i++), c3 = s.charAt(i++), c4 = s.charAt(i++);", + " var e1 = __B64.indexOf(c1), e2 = __B64.indexOf(c2), e3 = c3 === '' ? -1 : __B64.indexOf(c3), e4 = c4 === '' ? -1 : __B64.indexOf(c4);", + " o += String.fromCharCode((e1 << 2) | (e2 >> 4));", + " if (e3 !== -1) o += String.fromCharCode(((e2 & 15) << 4) | (e3 >> 2));", + " if (e4 !== -1) o += String.fromCharCode(((e3 & 3) << 6) | e4);", + " } return o; }", + "function __safe(x){ try { JSON.stringify(x); return x; } catch (e) { return String(x); } }", + "function __emit(t, a){ try { __logs.push({ type: t, args: Array.prototype.map.call(a, __safe) }); } catch (e) {} }", + "var 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); } };", + "var args = JSON.parse(__argsJson);", + "var $sharedState = JSON.parse(__sharedStateJson);", + "var __OUTPUT = null;", +].join(""); + +/** + * Verify a rule's code string parses WITHOUT executing it. Constructing + * `new Function(body)` compiles/parses the body but never runs it (the function + * is never called), so even an IIFE-shaped string cannot execute here. Avoids the + * `vm` module (unsupported in Electron's renderer); the sandboxed execution + * happens inside QuickJS. + */ +export const isValidFunctionString = async function ( + functionStringEscaped: string +): Promise { + try { + // eslint-disable-next-line no-new, no-new-func + new Function(`return (${functionStringEscaped}\n);`); + return true; + } catch { + return false; + } }; +/* Expects that `functionString` has already been validated via isValidFunctionString. */ +export async function executeUserFunction( + ctx: any, + functionString: string, + args: any +): Promise { + let argsJson = "{}"; + let sharedStateJson = "{}"; + try { + argsJson = JSON.stringify(args ?? {}); + } catch { + argsJson = "{}"; + } + try { + sharedStateJson = JSON.stringify( + GlobalStateProvider.getInstance().getSharedStateCopy() ?? {} + ); + } catch { + sharedStateJson = "{}"; + } -/* Expects that the functionString has already been validated to be representing a proper function */ -export async function executeUserFunction(ctx, functionString: string, args) { + const QuickJS = await getQuickJSModule(); + const vm = QuickJS.newContext(); - const generateFunctionWithSharedState = function (functionStringEscaped) { + try { + vm.runtime.setMemoryLimit(MEMORY_LIMIT_BYTES); + vm.runtime.setMaxStackSize(MAX_STACK_BYTES); + // Hard wall-clock cap — interrupts infinite loops (sync and inside microtasks). + vm.runtime.setInterruptHandler( + shouldInterruptAfterDeadline(Date.now() + EXEC_TIMEOUT_MS) + ); - 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); - }; + // Inject inputs as primitive strings (parsed into objects inside the sandbox). + const argsHandle = vm.newString(argsJson); + vm.setProp(vm.global, "__argsJson", argsHandle); + argsHandle.dispose(); + const sharedHandle = vm.newString(sharedStateJson); + vm.setProp(vm.global, "__sharedStateJson", sharedHandle); + sharedHandle.dispose(); + + // The user fn is appended after a newline so a trailing '//' comment can't + // swallow the marshaling code. Result (or error) + console + $sharedState are + // serialized into the __OUTPUT global, which we read back on the host side. + const program = + SANDBOX_SETUP + + "Promise.resolve((" + + functionString + + "\n)(args)).then(function (r) {" + + " var out;" + + " if (r === undefined || r === null) { out = r; }" + + ' else if (typeof r === "object") { out = JSON.stringify(r); }' + + " else { out = r; }" + + " __OUTPUT = JSON.stringify({ result: out, sharedState: $sharedState, logs: __logs });" + + "}).catch(function (e) {" + + " __OUTPUT = JSON.stringify({ error: String((e && e.message) || e), logs: __logs });" + + "});"; + + const evalResult = vm.evalCode(program); + if (evalResult.error) { + // Syntax/throw at the top level (outside the user fn's promise). + evalResult.error.dispose(); + return undefined; + } + // Success variant — dispose the completion value (we read __OUTPUT instead). + (evalResult as { value: { dispose(): void } }).value.dispose(); - const {func: generatedFunction, updatedSharedState} = generateFunctionWithSharedState(functionString); - - const consoleCapture = new ConsoleCapture() - consoleCapture.start(true) + // Resolve the user fn's (possibly async-but-IO-free) promise microtasks. + vm.runtime.executePendingJobs(); - let finalResponse = generatedFunction(args); + const outHandle = vm.getProp(vm.global, "__OUTPUT"); + const output = vm.dump(outHandle); + outHandle.dispose(); - if (types.isPromise(finalResponse)) { - finalResponse = await finalResponse; + if (typeof output !== "string") { + // Promise never settled (e.g. unsupported real async) → no modification. + return 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); + let parsed: any; + try { + parsed = JSON.parse(output); + } catch { + return undefined; + } + + if (parsed.logs?.length && ctx?.rq?.consoleLogs) { + ctx.rq.consoleLogs.push(...parsed.logs); + } + + if (parsed.error) { + if (ctx?.rq?.consoleLogs) { + ctx.rq.consoleLogs.push({ type: "error", args: [String(parsed.error)] }); } + return undefined; + } + + // Write back any mutations the rule made to $sharedState. + GlobalStateProvider.getInstance().setSharedState(parsed.sharedState ?? {}); - return finalResponse; -} \ No newline at end of file + // Objects were JSON-stringified inside the sandbox, so result is a string + // (or null/undefined) — mirrors the previous return contract. + return parsed.result; + } finally { + vm.dispose(); + } +}