From 2f3294089195c5fc3b29db93e410b21c76c11ad3 Mon Sep 17 00:00:00 2001 From: dinex-dev Date: Tue, 23 Jun 2026 16:53:40 +0530 Subject: [PATCH 1/9] 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(); + } +} From ef1839ddd1c60adcefa1270ec633ba2afa401857 Mon Sep 17 00:00:00 2001 From: dinex-dev Date: Wed, 24 Jun 2026 11:44:19 +0530 Subject: [PATCH 2/9] feat(sandbox): add pure-JS web-API polyfills for rule code (RQ-2426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QuickJS is a bare ECMAScript engine — it lacks the Web/Node globals rule code commonly expects. Add them as PURE-JS shims that run inside the sandbox, built only from QuickJS built-ins so no host object crosses the boundary (same safety model as atob/btoa; verified require/process stay undefined in-sandbox): - URL / URLSearchParams (regex-based parser; common cases — protocol/host/port/ path/search/hash/origin, searchParams, basic relative-base resolution) - TextEncoder / TextDecoder (UTF-8) - structuredClone (deep clone, preserves Date/Map/Set, handles cycles) - crypto.randomUUID / crypto.getRandomValues NOTE on crypto: this is a Math.random-based STOPGAP — NOT cryptographically secure (no entropy source in the WASM realm). Fine for ids/non-security random; secure crypto should be a host bridge (follow-up), matching @requestly/sandbox-node's byte-identical-to-host crypto approach. Aligns with the API client's QuickJS sandbox model: pure-JS shims for computation-only APIs, host bridge reserved for capability APIs (crypto/fetch). URL is hand-rolled (sandbox-node doesn't expose URL — uses a regex internally — so this is a superset of theirs). Co-Authored-By: Claude Opus 4.8 (1M context) --- dist/utils/index.js | 181 +++++++++++++++++++++++++++++++++++++++++++- src/utils/index.ts | 180 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 360 insertions(+), 1 deletion(-) diff --git a/dist/utils/index.js b/dist/utils/index.js index 7a5e448..bb0a12e 100644 --- a/dist/utils/index.js +++ b/dist/utils/index.js @@ -77,6 +77,184 @@ const SANDBOX_SETUP = [ "var $sharedState = JSON.parse(__sharedStateJson);", "var __OUTPUT = null;", ].join(""); +// Pure-JS polyfills for common Web/Node globals that QuickJS (a bare ECMAScript +// engine) does not provide. All implemented INSIDE the sandbox using only QuickJS +// built-ins — no host object crosses the boundary, so they add no escape surface +// (same safety model as atob/btoa). `String.raw` keeps regex backslashes literal. +// NOTE: `crypto` here is NOT cryptographically secure (Math.random-based) — it is +// only for id/random generation; secure RNG / crypto.subtle would need a host bridge. +const SANDBOX_POLYFILLS = String.raw ` +(function (g) { + "use strict"; + + // ---- URLSearchParams ---- + function URLSearchParams(init) { + this.__l = []; + var self = this; + if (init == null || init === "") { /* empty */ } + else if (typeof init === "string") { + var s = init.charAt(0) === "?" ? init.slice(1) : init; + if (s.length) s.split("&").forEach(function (pair) { + if (pair === "") return; + var idx = pair.indexOf("="); + var k = idx === -1 ? pair : pair.slice(0, idx); + var v = idx === -1 ? "" : pair.slice(idx + 1); + self.__l.push([decodeURIComponent(k.replace(/\+/g, " ")), decodeURIComponent(v.replace(/\+/g, " "))]); + }); + } else if (init instanceof Array) { + init.forEach(function (p) { self.__l.push([String(p[0]), String(p[1])]); }); + } else if (typeof init.forEach === "function") { + init.forEach(function (v, k) { self.__l.push([String(k), String(v)]); }); + } else if (typeof init === "object") { + for (var key in init) if (Object.prototype.hasOwnProperty.call(init, key)) self.__l.push([key, String(init[key])]); + } + } + URLSearchParams.prototype.append = function (k, v) { this.__l.push([String(k), String(v)]); }; + URLSearchParams.prototype["delete"] = function (k) { k = String(k); this.__l = this.__l.filter(function (e) { return e[0] !== k; }); }; + URLSearchParams.prototype.get = function (k) { k = String(k); for (var i = 0; i < this.__l.length; i++) if (this.__l[i][0] === k) return this.__l[i][1]; return null; }; + URLSearchParams.prototype.getAll = function (k) { k = String(k); var r = []; for (var i = 0; i < this.__l.length; i++) if (this.__l[i][0] === k) r.push(this.__l[i][1]); return r; }; + URLSearchParams.prototype.has = function (k) { return this.get(String(k)) !== null; }; + URLSearchParams.prototype.set = function (k, v) { + k = String(k); v = String(v); var found = false; var out = []; + for (var i = 0; i < this.__l.length; i++) { + if (this.__l[i][0] === k) { if (!found) { out.push([k, v]); found = true; } } + else out.push(this.__l[i]); + } + if (!found) out.push([k, v]); this.__l = out; + }; + URLSearchParams.prototype.forEach = function (cb, t) { for (var i = 0; i < this.__l.length; i++) cb.call(t, this.__l[i][1], this.__l[i][0], this); }; + URLSearchParams.prototype.keys = function () { return this.__l.map(function (e) { return e[0]; }); }; + URLSearchParams.prototype.values = function () { return this.__l.map(function (e) { return e[1]; }); }; + URLSearchParams.prototype.entries = function () { return this.__l.map(function (e) { return [e[0], e[1]]; }); }; + URLSearchParams.prototype.sort = function () { this.__l.sort(function (a, b) { return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0; }); }; + URLSearchParams.prototype.toString = function () { return this.__l.map(function (e) { return encodeURIComponent(e[0]) + "=" + encodeURIComponent(e[1]); }).join("&"); }; + + // ---- URL ---- + var URL_RE = /^(?:([^:/?#]+):)?(?:\/\/(?:([^/?#@]*)@)?([^/?#:]*)(?::(\d+))?)?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/; + function URL(url, base) { + url = String(url); + if (base != null && !/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(url)) { + var b = new URL(String(base)); + if (url.indexOf("//") === 0) url = b.protocol + url; + else if (url.charAt(0) === "/") url = b.protocol + "//" + b.host + url; + else if (url.charAt(0) === "?") url = b.protocol + "//" + b.host + b.pathname + url; + else if (url.charAt(0) === "#") url = b.protocol + "//" + b.host + b.pathname + b.search + url; + else url = b.protocol + "//" + b.host + b.pathname.replace(/[^/]*$/, "") + url; + } + var m = url.match(URL_RE); + if (!m || !m[1]) throw new TypeError("Invalid URL: " + url); + this.protocol = m[1].toLowerCase() + ":"; + var auth = m[2] || "", ai = auth.indexOf(":"); + this.username = ai === -1 ? auth : auth.slice(0, ai); + this.password = ai === -1 ? "" : auth.slice(ai + 1); + this.hostname = (m[3] || "").toLowerCase(); + this.port = m[4] || ""; + this.host = this.hostname + (this.port ? ":" + this.port : ""); + this.pathname = m[5] || (this.hostname ? "/" : ""); + this.search = (m[6] != null && m[6] !== "") ? "?" + m[6] : ""; + this.hash = (m[7] != null && m[7] !== "") ? "#" + m[7] : ""; + this.searchParams = new URLSearchParams(this.search); + var sp = this.protocol; + var special = sp === "http:" || sp === "https:" || sp === "ftp:" || sp === "ws:" || sp === "wss:"; + this.origin = (special && this.hostname) ? (this.protocol + "//" + this.host) : "null"; + } + Object.defineProperty(URL.prototype, "href", { + get: function () { + var auth = this.username ? (this.username + (this.password ? ":" + this.password : "") + "@") : ""; + var search = this.searchParams && this.searchParams.toString ? this.searchParams.toString() : ""; + search = search ? "?" + search : ""; + var hostPart = this.host ? ("//" + auth + this.host) : (this.protocol === "file:" ? "//" : ""); + return this.protocol + hostPart + this.pathname + search + this.hash; + }, + set: function (v) { URL.call(this, v); } + }); + URL.prototype.toString = function () { return this.href; }; + URL.prototype.toJSON = function () { return this.href; }; + + // ---- TextEncoder / TextDecoder (UTF-8) ---- + function TextEncoder() {} + TextEncoder.prototype.encode = function (str) { + str = String(str === undefined ? "" : str); + var out = []; + for (var i = 0; i < str.length; i++) { + var c = str.charCodeAt(i); + if (c < 0x80) out.push(c); + else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); + else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) { + var c2 = str.charCodeAt(i + 1); + if (c2 >= 0xdc00 && c2 <= 0xdfff) { + var cp = 0x10000 + ((c - 0xd800) << 10) + (c2 - 0xdc00); + out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + i++; + } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } + return new Uint8Array(out); + }; + function TextDecoder() {} + TextDecoder.prototype.decode = function (buf) { + if (!buf) return ""; + var bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf.buffer || buf); + var out = "", i = 0; + while (i < bytes.length) { + var b = bytes[i++]; + if (b < 0x80) out += String.fromCharCode(b); + else if (b >= 0xc0 && b < 0xe0) out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f)); + else if (b >= 0xe0 && b < 0xf0) out += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f)); + else { + var cp2 = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f); + cp2 -= 0x10000; + out += String.fromCharCode(0xd800 + (cp2 >> 10), 0xdc00 + (cp2 & 0x3ff)); + } + } + return out; + }; + + // ---- structuredClone ---- + function structuredClone(value) { + function cl(x, seen) { + if (x === null || typeof x !== "object") return x; + if (seen.has(x)) return seen.get(x); + if (x instanceof Date) return new Date(x.getTime()); + if (x instanceof RegExp) return new RegExp(x.source, x.flags); + var out; + if (Array.isArray(x)) { out = []; seen.set(x, out); for (var i = 0; i < x.length; i++) out[i] = cl(x[i], seen); return out; } + if (x instanceof Map) { out = new Map(); seen.set(x, out); x.forEach(function (v, k) { out.set(cl(k, seen), cl(v, seen)); }); return out; } + if (x instanceof Set) { out = new Set(); seen.set(x, out); x.forEach(function (v) { out.add(cl(v, seen)); }); return out; } + out = {}; seen.set(x, out); + for (var key in x) if (Object.prototype.hasOwnProperty.call(x, key)) out[key] = cl(x[key], seen); + return out; + } + return cl(value, new Map()); + } + + // ---- crypto (NOT cryptographically secure — Math.random based) ---- + var crypto = { + getRandomValues: function (arr) { + var max = arr && arr.BYTES_PER_ELEMENT ? Math.pow(256, arr.BYTES_PER_ELEMENT) : 256; + for (var i = 0; i < arr.length; i++) arr[i] = Math.floor(Math.random() * max); + return arr; + }, + randomUUID: function () { + var s = ""; + for (var i = 0; i < 36; i++) { + if (i === 8 || i === 13 || i === 18 || i === 23) s += "-"; + else if (i === 14) s += "4"; + else if (i === 19) s += (8 + Math.floor(Math.random() * 4)).toString(16); + else s += Math.floor(Math.random() * 16).toString(16); + } + return s; + } + }; + + g.URLSearchParams = URLSearchParams; + g.URL = URL; + g.TextEncoder = TextEncoder; + g.TextDecoder = TextDecoder; + g.structuredClone = structuredClone; + g.crypto = crypto; +})(typeof globalThis !== "undefined" ? globalThis : this); +`; /** * Verify a rule's code string parses WITHOUT executing it. Constructing * `new Function(body)` compiles/parses the body but never runs it (the function @@ -129,7 +307,8 @@ async function executeUserFunction(ctx, functionString, args) { // 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 + + const program = SANDBOX_POLYFILLS + + SANDBOX_SETUP + "Promise.resolve((" + functionString + "\n)(args)).then(function (r) {" + diff --git a/src/utils/index.ts b/src/utils/index.ts index 0b66ec2..89859c2 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -79,6 +79,185 @@ const SANDBOX_SETUP = [ "var __OUTPUT = null;", ].join(""); +// Pure-JS polyfills for common Web/Node globals that QuickJS (a bare ECMAScript +// engine) does not provide. All implemented INSIDE the sandbox using only QuickJS +// built-ins — no host object crosses the boundary, so they add no escape surface +// (same safety model as atob/btoa). `String.raw` keeps regex backslashes literal. +// NOTE: `crypto` here is NOT cryptographically secure (Math.random-based) — it is +// only for id/random generation; secure RNG / crypto.subtle would need a host bridge. +const SANDBOX_POLYFILLS = String.raw` +(function (g) { + "use strict"; + + // ---- URLSearchParams ---- + function URLSearchParams(init) { + this.__l = []; + var self = this; + if (init == null || init === "") { /* empty */ } + else if (typeof init === "string") { + var s = init.charAt(0) === "?" ? init.slice(1) : init; + if (s.length) s.split("&").forEach(function (pair) { + if (pair === "") return; + var idx = pair.indexOf("="); + var k = idx === -1 ? pair : pair.slice(0, idx); + var v = idx === -1 ? "" : pair.slice(idx + 1); + self.__l.push([decodeURIComponent(k.replace(/\+/g, " ")), decodeURIComponent(v.replace(/\+/g, " "))]); + }); + } else if (init instanceof Array) { + init.forEach(function (p) { self.__l.push([String(p[0]), String(p[1])]); }); + } else if (typeof init.forEach === "function") { + init.forEach(function (v, k) { self.__l.push([String(k), String(v)]); }); + } else if (typeof init === "object") { + for (var key in init) if (Object.prototype.hasOwnProperty.call(init, key)) self.__l.push([key, String(init[key])]); + } + } + URLSearchParams.prototype.append = function (k, v) { this.__l.push([String(k), String(v)]); }; + URLSearchParams.prototype["delete"] = function (k) { k = String(k); this.__l = this.__l.filter(function (e) { return e[0] !== k; }); }; + URLSearchParams.prototype.get = function (k) { k = String(k); for (var i = 0; i < this.__l.length; i++) if (this.__l[i][0] === k) return this.__l[i][1]; return null; }; + URLSearchParams.prototype.getAll = function (k) { k = String(k); var r = []; for (var i = 0; i < this.__l.length; i++) if (this.__l[i][0] === k) r.push(this.__l[i][1]); return r; }; + URLSearchParams.prototype.has = function (k) { return this.get(String(k)) !== null; }; + URLSearchParams.prototype.set = function (k, v) { + k = String(k); v = String(v); var found = false; var out = []; + for (var i = 0; i < this.__l.length; i++) { + if (this.__l[i][0] === k) { if (!found) { out.push([k, v]); found = true; } } + else out.push(this.__l[i]); + } + if (!found) out.push([k, v]); this.__l = out; + }; + URLSearchParams.prototype.forEach = function (cb, t) { for (var i = 0; i < this.__l.length; i++) cb.call(t, this.__l[i][1], this.__l[i][0], this); }; + URLSearchParams.prototype.keys = function () { return this.__l.map(function (e) { return e[0]; }); }; + URLSearchParams.prototype.values = function () { return this.__l.map(function (e) { return e[1]; }); }; + URLSearchParams.prototype.entries = function () { return this.__l.map(function (e) { return [e[0], e[1]]; }); }; + URLSearchParams.prototype.sort = function () { this.__l.sort(function (a, b) { return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0; }); }; + URLSearchParams.prototype.toString = function () { return this.__l.map(function (e) { return encodeURIComponent(e[0]) + "=" + encodeURIComponent(e[1]); }).join("&"); }; + + // ---- URL ---- + var URL_RE = /^(?:([^:/?#]+):)?(?:\/\/(?:([^/?#@]*)@)?([^/?#:]*)(?::(\d+))?)?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/; + function URL(url, base) { + url = String(url); + if (base != null && !/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(url)) { + var b = new URL(String(base)); + if (url.indexOf("//") === 0) url = b.protocol + url; + else if (url.charAt(0) === "/") url = b.protocol + "//" + b.host + url; + else if (url.charAt(0) === "?") url = b.protocol + "//" + b.host + b.pathname + url; + else if (url.charAt(0) === "#") url = b.protocol + "//" + b.host + b.pathname + b.search + url; + else url = b.protocol + "//" + b.host + b.pathname.replace(/[^/]*$/, "") + url; + } + var m = url.match(URL_RE); + if (!m || !m[1]) throw new TypeError("Invalid URL: " + url); + this.protocol = m[1].toLowerCase() + ":"; + var auth = m[2] || "", ai = auth.indexOf(":"); + this.username = ai === -1 ? auth : auth.slice(0, ai); + this.password = ai === -1 ? "" : auth.slice(ai + 1); + this.hostname = (m[3] || "").toLowerCase(); + this.port = m[4] || ""; + this.host = this.hostname + (this.port ? ":" + this.port : ""); + this.pathname = m[5] || (this.hostname ? "/" : ""); + this.search = (m[6] != null && m[6] !== "") ? "?" + m[6] : ""; + this.hash = (m[7] != null && m[7] !== "") ? "#" + m[7] : ""; + this.searchParams = new URLSearchParams(this.search); + var sp = this.protocol; + var special = sp === "http:" || sp === "https:" || sp === "ftp:" || sp === "ws:" || sp === "wss:"; + this.origin = (special && this.hostname) ? (this.protocol + "//" + this.host) : "null"; + } + Object.defineProperty(URL.prototype, "href", { + get: function () { + var auth = this.username ? (this.username + (this.password ? ":" + this.password : "") + "@") : ""; + var search = this.searchParams && this.searchParams.toString ? this.searchParams.toString() : ""; + search = search ? "?" + search : ""; + var hostPart = this.host ? ("//" + auth + this.host) : (this.protocol === "file:" ? "//" : ""); + return this.protocol + hostPart + this.pathname + search + this.hash; + }, + set: function (v) { URL.call(this, v); } + }); + URL.prototype.toString = function () { return this.href; }; + URL.prototype.toJSON = function () { return this.href; }; + + // ---- TextEncoder / TextDecoder (UTF-8) ---- + function TextEncoder() {} + TextEncoder.prototype.encode = function (str) { + str = String(str === undefined ? "" : str); + var out = []; + for (var i = 0; i < str.length; i++) { + var c = str.charCodeAt(i); + if (c < 0x80) out.push(c); + else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); + else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) { + var c2 = str.charCodeAt(i + 1); + if (c2 >= 0xdc00 && c2 <= 0xdfff) { + var cp = 0x10000 + ((c - 0xd800) << 10) + (c2 - 0xdc00); + out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + i++; + } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } + return new Uint8Array(out); + }; + function TextDecoder() {} + TextDecoder.prototype.decode = function (buf) { + if (!buf) return ""; + var bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf.buffer || buf); + var out = "", i = 0; + while (i < bytes.length) { + var b = bytes[i++]; + if (b < 0x80) out += String.fromCharCode(b); + else if (b >= 0xc0 && b < 0xe0) out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f)); + else if (b >= 0xe0 && b < 0xf0) out += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f)); + else { + var cp2 = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f); + cp2 -= 0x10000; + out += String.fromCharCode(0xd800 + (cp2 >> 10), 0xdc00 + (cp2 & 0x3ff)); + } + } + return out; + }; + + // ---- structuredClone ---- + function structuredClone(value) { + function cl(x, seen) { + if (x === null || typeof x !== "object") return x; + if (seen.has(x)) return seen.get(x); + if (x instanceof Date) return new Date(x.getTime()); + if (x instanceof RegExp) return new RegExp(x.source, x.flags); + var out; + if (Array.isArray(x)) { out = []; seen.set(x, out); for (var i = 0; i < x.length; i++) out[i] = cl(x[i], seen); return out; } + if (x instanceof Map) { out = new Map(); seen.set(x, out); x.forEach(function (v, k) { out.set(cl(k, seen), cl(v, seen)); }); return out; } + if (x instanceof Set) { out = new Set(); seen.set(x, out); x.forEach(function (v) { out.add(cl(v, seen)); }); return out; } + out = {}; seen.set(x, out); + for (var key in x) if (Object.prototype.hasOwnProperty.call(x, key)) out[key] = cl(x[key], seen); + return out; + } + return cl(value, new Map()); + } + + // ---- crypto (NOT cryptographically secure — Math.random based) ---- + var crypto = { + getRandomValues: function (arr) { + var max = arr && arr.BYTES_PER_ELEMENT ? Math.pow(256, arr.BYTES_PER_ELEMENT) : 256; + for (var i = 0; i < arr.length; i++) arr[i] = Math.floor(Math.random() * max); + return arr; + }, + randomUUID: function () { + var s = ""; + for (var i = 0; i < 36; i++) { + if (i === 8 || i === 13 || i === 18 || i === 23) s += "-"; + else if (i === 14) s += "4"; + else if (i === 19) s += (8 + Math.floor(Math.random() * 4)).toString(16); + else s += Math.floor(Math.random() * 16).toString(16); + } + return s; + } + }; + + g.URLSearchParams = URLSearchParams; + g.URL = URL; + g.TextEncoder = TextEncoder; + g.TextDecoder = TextDecoder; + g.structuredClone = structuredClone; + g.crypto = crypto; +})(typeof globalThis !== "undefined" ? globalThis : this); +`; + /** * Verify a rule's code string parses WITHOUT executing it. Constructing * `new Function(body)` compiles/parses the body but never runs it (the function @@ -142,6 +321,7 @@ export async function executeUserFunction( // 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_POLYFILLS + SANDBOX_SETUP + "Promise.resolve((" + functionString + From fbfe3c23cf220d0c8908ad1c7bc9117e96cce10e Mon Sep 17 00:00:00 2001 From: dinex-dev Date: Wed, 24 Jun 2026 11:49:16 +0530 Subject: [PATCH 3/9] chore: restore proxy version to 1.5.0 (matches master) The QuickJS sandbox commit was assembled from a checkpoint based on an older master and inadvertently reverted package.json/package-lock from 1.5.0 to 1.4.0. Restore to 1.5.0 so the branch's only package.json delta vs master is the added quickjs-emscripten deps. Dependency versions left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1404d8f..6b20a39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@requestly/requestly-proxy", - "version": "1.4.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@requestly/requestly-proxy", - "version": "1.4.0", + "version": "1.5.0", "license": "ISC", "dependencies": { "@jitl/quickjs-singlefile-cjs-release-sync": "^0.32.0", diff --git a/package.json b/package.json index 7a1e7f1..00e8c3e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@requestly/requestly-proxy", - "version": "1.4.0", + "version": "1.5.0", "description": "Proxy that gives superpowers to all the Requestly clients", "main": "dist/index.js", "types": "dist/index.d.ts", From b7d6aed2b09a5f43566b56ba18404dee90e84c8e Mon Sep 17 00:00:00 2001 From: dinex-dev Date: Wed, 24 Jun 2026 11:57:39 +0530 Subject: [PATCH 4/9] fix(sandbox): address CodeRabbit review (RQ-2426) - Read the $sharedState snapshot after the last await so the snapshot->eval-> setSharedState read-modify-write is atomic w.r.t. the event loop. Reading it before the await let a concurrent executeUserFunction commit in the gap, whose update this call's stale snapshot would then clobber (last-writer-wins). - Capture executePendingJobs() result and dispose its error handle (carried on a job error / deadline interrupt) instead of discarding it. Co-Authored-By: Claude Opus 4.8 (1M context) --- dist/utils/index.js | 15 ++++++++++++--- src/utils/index.ts | 16 +++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/dist/utils/index.js b/dist/utils/index.js index bb0a12e..c6a4bd7 100644 --- a/dist/utils/index.js +++ b/dist/utils/index.js @@ -277,20 +277,25 @@ exports.isValidFunctionString = isValidFunctionString; async function executeUserFunction(ctx, functionString, args) { var _a, _b, _c, _d, _e; let argsJson = "{}"; - let sharedStateJson = "{}"; try { argsJson = JSON.stringify(args !== null && args !== void 0 ? args : {}); } catch (_f) { argsJson = "{}"; } + const QuickJS = await getQuickJSModule(); + // Read the $sharedState snapshot AFTER the last await. Everything from here + // to setSharedState() below runs synchronously (no further yields), so the + // read-modify-write is atomic w.r.t. the event loop. Reading before the + // await would let a concurrent executeUserFunction commit in the gap, and + // this call's stale snapshot would then clobber it (last-writer-wins). + let sharedStateJson = "{}"; 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); @@ -329,7 +334,11 @@ async function executeUserFunction(ctx, functionString, args) { // 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(); + // On a job error / deadline interrupt the result carries a QuickJSHandle; + // dispose it eagerly (vm.dispose() in finally would reclaim it too). + const jobs = vm.runtime.executePendingJobs(); + if (jobs.error) + jobs.error.dispose(); const outHandle = vm.getProp(vm.global, "__OUTPUT"); const output = vm.dump(outHandle); outHandle.dispose(); diff --git a/src/utils/index.ts b/src/utils/index.ts index 89859c2..891f551 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -284,12 +284,20 @@ export async function executeUserFunction( args: any ): Promise { let argsJson = "{}"; - let sharedStateJson = "{}"; try { argsJson = JSON.stringify(args ?? {}); } catch { argsJson = "{}"; } + + const QuickJS = await getQuickJSModule(); + + // Read the $sharedState snapshot AFTER the last await. Everything from here + // to setSharedState() below runs synchronously (no further yields), so the + // read-modify-write is atomic w.r.t. the event loop. Reading before the + // await would let a concurrent executeUserFunction commit in the gap, and + // this call's stale snapshot would then clobber it (last-writer-wins). + let sharedStateJson = "{}"; try { sharedStateJson = JSON.stringify( GlobalStateProvider.getInstance().getSharedStateCopy() ?? {} @@ -298,7 +306,6 @@ export async function executeUserFunction( sharedStateJson = "{}"; } - const QuickJS = await getQuickJSModule(); const vm = QuickJS.newContext(); try { @@ -345,7 +352,10 @@ export async function executeUserFunction( (evalResult as { value: { dispose(): void } }).value.dispose(); // Resolve the user fn's (possibly async-but-IO-free) promise microtasks. - vm.runtime.executePendingJobs(); + // On a job error / deadline interrupt the result carries a QuickJSHandle; + // dispose it eagerly (vm.dispose() in finally would reclaim it too). + const jobs = vm.runtime.executePendingJobs(); + if (jobs.error) jobs.error.dispose(); const outHandle = vm.getProp(vm.global, "__OUTPUT"); const output = vm.dump(outHandle); From f9bd049fe74d72ae6e36ac259687b64cf312bcc8 Mon Sep 17 00:00:00 2001 From: dinex-dev Date: Thu, 25 Jun 2026 11:19:47 +0530 Subject: [PATCH 5/9] feat(sandbox): web/Node API compatibility layer for rule code (RQ-2426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule "Code" runs in the QuickJS-WASM sandbox (a bare ES realm), so any web/Node global a script used in the old full-host environment is otherwise missing. This adds a compatibility layer so existing scripts keep working, without weakening the isolation boundary — only JSON-serialisable data crosses the host edge. Capabilities, by mechanism: - Pure in-guest JS shims (no host contact): URL, URLSearchParams, TextEncoder/ Decoder, structuredClone, atob/btoa, Buffer, Blob, FormData, Request, Response, Headers, setTimeout/setInterval/clearTimeout/clearInterval, queueMicrotask, performance. - Host bridges (copy-in/copy-out only, no host object exposed): - crypto: randomUUID, getRandomValues, createHash, createHmac, subtle.digest - fetch: real HTTP via host fetch, driven by a guest-promise + pump loop. - XMLHttpRequest (async) layered on the fetch bridge. - require('crypto') maps to the crypto bridge; any other require(...) throws a guided error (fs/process/etc. stay absent by design). fetch policy: http(s) URLs only; credentials: 'omit' (a shared rule cannot ride the user's ambient cookies/sessions). Engine: import from quickjs-emscripten-core (not the umbrella quickjs-emscripten), whose auto-loader statically references every WASM variant and breaks bundlers (the desktop's webpack). core + the single embedded variant is bundler-safe. Refactor: the in-guest source strings moved to utils/sandbox-globals.ts (guest realm) so utils/index.ts is host orchestration only. Deliberate limitations: - WebSocket: constructor throws — a persistent connection can't outlive a per-request execution. - Synchronous XMLHttpRequest (open(..., false)): throws — QuickJS can't block; use async / fetch. - crypto.subtle: only digest implemented; sign/verify/importKey/generateKey deferred (CryptoKey objects can't cross the copy boundary). HMAC signing is available via require('crypto').createHmac. - setTimeout fires once via a microtask (delay not honoured); setInterval is a no-op (returns an id) so a per-request rule cannot spin forever. - fetch: FormData/Blob multipart parts cross as text (binary parts best-effort). No SSRF/private-IP hardening yet (tracked separately). Co-Authored-By: Claude Opus 4.8 (1M context) --- dist/utils/index.js | 412 +++++++++++++---------------- dist/utils/sandbox-globals.d.ts | 14 + dist/utils/sandbox-globals.js | 445 +++++++++++++++++++++++++++++++ package-lock.json | 54 +--- package.json | 2 +- src/utils/index.ts | 421 ++++++++++++++---------------- src/utils/sandbox-globals.ts | 446 ++++++++++++++++++++++++++++++++ 7 files changed, 1291 insertions(+), 503 deletions(-) create mode 100644 dist/utils/sandbox-globals.d.ts create mode 100644 dist/utils/sandbox-globals.js create mode 100644 src/utils/sandbox-globals.ts diff --git a/dist/utils/index.js b/dist/utils/index.js index c6a4bd7..ac64647 100644 --- a/dist/utils/index.js +++ b/dist/utils/index.js @@ -6,8 +6,15 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.isValidFunctionString = void 0; exports.executeUserFunction = executeUserFunction; const quickjs_singlefile_cjs_release_sync_1 = __importDefault(require("@jitl/quickjs-singlefile-cjs-release-sync")); -const quickjs_emscripten_1 = require("quickjs-emscripten"); +// Import from quickjs-emscripten-core (lean, bring-your-own-variant) rather than +// the umbrella `quickjs-emscripten`: the umbrella's auto-loader statically +// references every WASM variant package, which a bundler (the desktop's webpack) +// tries to resolve and fails on. core + our single embedded variant is +// bundler-safe. (Same dependency choice as @requestly/sandbox-node.) +const quickjs_emscripten_core_1 = require("quickjs-emscripten-core"); +const crypto_1 = require("crypto"); const state_1 = __importDefault(require("../components/proxy-middleware/middlewares/state")); +const sandbox_globals_1 = require("./sandbox-globals"); /** * 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 @@ -32,11 +39,22 @@ const state_1 = __importDefault(require("../components/proxy-middleware/middlewa * 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.) + * back. + * + * Web-API compatibility (so existing rule scripts don't break): `URL`, + * `URLSearchParams`, `TextEncoder`/`TextDecoder`, `structuredClone`, `atob`/`btoa` + * are pure in-guest JS shims (no host contact). `crypto` and `fetch` are HOST + * BRIDGES — the guest calls a host function that does the real work with COPIED + * data and returns copied data; no host object ever crosses the boundary, so the + * isolation guarantee is unchanged (see __hostCrypto/__hostFetch below). `fetch` + * uses the guest-promise + pump-loop pattern (works on the sync QuickJS variant; + * avoids the asyncify teardown race). `require('crypto')` maps to the same bridge; + * any other `require(...)` throws a guided error (fs/process/etc. stay absent). */ -const EXEC_TIMEOUT_MS = 5000; +const EXEC_TIMEOUT_MS = 5000; // per-step CPU/interrupt deadline (sync guest bursts) +const OVERALL_TIMEOUT_MS = 15000; // wall-clock cap incl. async host I/O (fetch) +const FETCH_TIMEOUT_MS = 10000; // per fetch() call +const MAX_FETCH_BODY_BYTES = 25 * 1024 * 1024; 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 @@ -44,217 +62,10 @@ const MAX_STACK_BYTES = 2 * 1024 * 1024; let modulePromise = null; function getQuickJSModule() { if (!modulePromise) { - modulePromise = (0, quickjs_emscripten_1.newQuickJSWASMModuleFromVariant)(quickjs_singlefile_cjs_release_sync_1.default); + modulePromise = (0, quickjs_emscripten_core_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(""); -// Pure-JS polyfills for common Web/Node globals that QuickJS (a bare ECMAScript -// engine) does not provide. All implemented INSIDE the sandbox using only QuickJS -// built-ins — no host object crosses the boundary, so they add no escape surface -// (same safety model as atob/btoa). `String.raw` keeps regex backslashes literal. -// NOTE: `crypto` here is NOT cryptographically secure (Math.random-based) — it is -// only for id/random generation; secure RNG / crypto.subtle would need a host bridge. -const SANDBOX_POLYFILLS = String.raw ` -(function (g) { - "use strict"; - - // ---- URLSearchParams ---- - function URLSearchParams(init) { - this.__l = []; - var self = this; - if (init == null || init === "") { /* empty */ } - else if (typeof init === "string") { - var s = init.charAt(0) === "?" ? init.slice(1) : init; - if (s.length) s.split("&").forEach(function (pair) { - if (pair === "") return; - var idx = pair.indexOf("="); - var k = idx === -1 ? pair : pair.slice(0, idx); - var v = idx === -1 ? "" : pair.slice(idx + 1); - self.__l.push([decodeURIComponent(k.replace(/\+/g, " ")), decodeURIComponent(v.replace(/\+/g, " "))]); - }); - } else if (init instanceof Array) { - init.forEach(function (p) { self.__l.push([String(p[0]), String(p[1])]); }); - } else if (typeof init.forEach === "function") { - init.forEach(function (v, k) { self.__l.push([String(k), String(v)]); }); - } else if (typeof init === "object") { - for (var key in init) if (Object.prototype.hasOwnProperty.call(init, key)) self.__l.push([key, String(init[key])]); - } - } - URLSearchParams.prototype.append = function (k, v) { this.__l.push([String(k), String(v)]); }; - URLSearchParams.prototype["delete"] = function (k) { k = String(k); this.__l = this.__l.filter(function (e) { return e[0] !== k; }); }; - URLSearchParams.prototype.get = function (k) { k = String(k); for (var i = 0; i < this.__l.length; i++) if (this.__l[i][0] === k) return this.__l[i][1]; return null; }; - URLSearchParams.prototype.getAll = function (k) { k = String(k); var r = []; for (var i = 0; i < this.__l.length; i++) if (this.__l[i][0] === k) r.push(this.__l[i][1]); return r; }; - URLSearchParams.prototype.has = function (k) { return this.get(String(k)) !== null; }; - URLSearchParams.prototype.set = function (k, v) { - k = String(k); v = String(v); var found = false; var out = []; - for (var i = 0; i < this.__l.length; i++) { - if (this.__l[i][0] === k) { if (!found) { out.push([k, v]); found = true; } } - else out.push(this.__l[i]); - } - if (!found) out.push([k, v]); this.__l = out; - }; - URLSearchParams.prototype.forEach = function (cb, t) { for (var i = 0; i < this.__l.length; i++) cb.call(t, this.__l[i][1], this.__l[i][0], this); }; - URLSearchParams.prototype.keys = function () { return this.__l.map(function (e) { return e[0]; }); }; - URLSearchParams.prototype.values = function () { return this.__l.map(function (e) { return e[1]; }); }; - URLSearchParams.prototype.entries = function () { return this.__l.map(function (e) { return [e[0], e[1]]; }); }; - URLSearchParams.prototype.sort = function () { this.__l.sort(function (a, b) { return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0; }); }; - URLSearchParams.prototype.toString = function () { return this.__l.map(function (e) { return encodeURIComponent(e[0]) + "=" + encodeURIComponent(e[1]); }).join("&"); }; - - // ---- URL ---- - var URL_RE = /^(?:([^:/?#]+):)?(?:\/\/(?:([^/?#@]*)@)?([^/?#:]*)(?::(\d+))?)?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/; - function URL(url, base) { - url = String(url); - if (base != null && !/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(url)) { - var b = new URL(String(base)); - if (url.indexOf("//") === 0) url = b.protocol + url; - else if (url.charAt(0) === "/") url = b.protocol + "//" + b.host + url; - else if (url.charAt(0) === "?") url = b.protocol + "//" + b.host + b.pathname + url; - else if (url.charAt(0) === "#") url = b.protocol + "//" + b.host + b.pathname + b.search + url; - else url = b.protocol + "//" + b.host + b.pathname.replace(/[^/]*$/, "") + url; - } - var m = url.match(URL_RE); - if (!m || !m[1]) throw new TypeError("Invalid URL: " + url); - this.protocol = m[1].toLowerCase() + ":"; - var auth = m[2] || "", ai = auth.indexOf(":"); - this.username = ai === -1 ? auth : auth.slice(0, ai); - this.password = ai === -1 ? "" : auth.slice(ai + 1); - this.hostname = (m[3] || "").toLowerCase(); - this.port = m[4] || ""; - this.host = this.hostname + (this.port ? ":" + this.port : ""); - this.pathname = m[5] || (this.hostname ? "/" : ""); - this.search = (m[6] != null && m[6] !== "") ? "?" + m[6] : ""; - this.hash = (m[7] != null && m[7] !== "") ? "#" + m[7] : ""; - this.searchParams = new URLSearchParams(this.search); - var sp = this.protocol; - var special = sp === "http:" || sp === "https:" || sp === "ftp:" || sp === "ws:" || sp === "wss:"; - this.origin = (special && this.hostname) ? (this.protocol + "//" + this.host) : "null"; - } - Object.defineProperty(URL.prototype, "href", { - get: function () { - var auth = this.username ? (this.username + (this.password ? ":" + this.password : "") + "@") : ""; - var search = this.searchParams && this.searchParams.toString ? this.searchParams.toString() : ""; - search = search ? "?" + search : ""; - var hostPart = this.host ? ("//" + auth + this.host) : (this.protocol === "file:" ? "//" : ""); - return this.protocol + hostPart + this.pathname + search + this.hash; - }, - set: function (v) { URL.call(this, v); } - }); - URL.prototype.toString = function () { return this.href; }; - URL.prototype.toJSON = function () { return this.href; }; - - // ---- TextEncoder / TextDecoder (UTF-8) ---- - function TextEncoder() {} - TextEncoder.prototype.encode = function (str) { - str = String(str === undefined ? "" : str); - var out = []; - for (var i = 0; i < str.length; i++) { - var c = str.charCodeAt(i); - if (c < 0x80) out.push(c); - else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); - else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) { - var c2 = str.charCodeAt(i + 1); - if (c2 >= 0xdc00 && c2 <= 0xdfff) { - var cp = 0x10000 + ((c - 0xd800) << 10) + (c2 - 0xdc00); - out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); - i++; - } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); - } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); - } - return new Uint8Array(out); - }; - function TextDecoder() {} - TextDecoder.prototype.decode = function (buf) { - if (!buf) return ""; - var bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf.buffer || buf); - var out = "", i = 0; - while (i < bytes.length) { - var b = bytes[i++]; - if (b < 0x80) out += String.fromCharCode(b); - else if (b >= 0xc0 && b < 0xe0) out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f)); - else if (b >= 0xe0 && b < 0xf0) out += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f)); - else { - var cp2 = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f); - cp2 -= 0x10000; - out += String.fromCharCode(0xd800 + (cp2 >> 10), 0xdc00 + (cp2 & 0x3ff)); - } - } - return out; - }; - - // ---- structuredClone ---- - function structuredClone(value) { - function cl(x, seen) { - if (x === null || typeof x !== "object") return x; - if (seen.has(x)) return seen.get(x); - if (x instanceof Date) return new Date(x.getTime()); - if (x instanceof RegExp) return new RegExp(x.source, x.flags); - var out; - if (Array.isArray(x)) { out = []; seen.set(x, out); for (var i = 0; i < x.length; i++) out[i] = cl(x[i], seen); return out; } - if (x instanceof Map) { out = new Map(); seen.set(x, out); x.forEach(function (v, k) { out.set(cl(k, seen), cl(v, seen)); }); return out; } - if (x instanceof Set) { out = new Set(); seen.set(x, out); x.forEach(function (v) { out.add(cl(v, seen)); }); return out; } - out = {}; seen.set(x, out); - for (var key in x) if (Object.prototype.hasOwnProperty.call(x, key)) out[key] = cl(x[key], seen); - return out; - } - return cl(value, new Map()); - } - - // ---- crypto (NOT cryptographically secure — Math.random based) ---- - var crypto = { - getRandomValues: function (arr) { - var max = arr && arr.BYTES_PER_ELEMENT ? Math.pow(256, arr.BYTES_PER_ELEMENT) : 256; - for (var i = 0; i < arr.length; i++) arr[i] = Math.floor(Math.random() * max); - return arr; - }, - randomUUID: function () { - var s = ""; - for (var i = 0; i < 36; i++) { - if (i === 8 || i === 13 || i === 18 || i === 23) s += "-"; - else if (i === 14) s += "4"; - else if (i === 19) s += (8 + Math.floor(Math.random() * 4)).toString(16); - else s += Math.floor(Math.random() * 16).toString(16); - } - return s; - } - }; - - g.URLSearchParams = URLSearchParams; - g.URL = URL; - g.TextEncoder = TextEncoder; - g.TextDecoder = TextDecoder; - g.structuredClone = structuredClone; - g.crypto = crypto; -})(typeof globalThis !== "undefined" ? globalThis : this); -`; /** * Verify a rule's code string parses WITHOUT executing it. Constructing * `new Function(body)` compiles/parses the body but never runs it (the function @@ -273,6 +84,90 @@ const isValidFunctionString = async function (functionStringEscaped) { } }; exports.isValidFunctionString = isValidFunctionString; +// ── host-side bridge handlers ── only copied data crosses the boundary. +/** Real crypto via the host's node:crypto. Input/output are plain JSON values. */ +function hostCryptoOp(req) { + switch (req === null || req === void 0 ? void 0 : req.op) { + case "randomUUID": + return { uuid: (0, crypto_1.randomUUID)() }; + case "randomBytes": { + const n = Math.max(0, Math.min(65536, Number(req.size) | 0)); + return { bytes: Array.from((0, crypto_1.randomBytes)(n)) }; + } + case "hash": { + const enc = req.encoding === "base64" ? "base64" : "hex"; + const data = Buffer.from(String(req.data), req.dataEncoding === "base64" ? "base64" : "utf8"); + return { + digest: (0, crypto_1.createHash)(String(req.algo || "sha256")) + .update(data) + .digest(enc), + }; + } + case "hmac": { + const enc = req.encoding === "base64" ? "base64" : "hex"; + const key = Buffer.from(String(req.key), req.keyEncoding === "base64" ? "base64" : "utf8"); + const data = Buffer.from(String(req.data), req.dataEncoding === "base64" ? "base64" : "utf8"); + return { + digest: (0, crypto_1.createHmac)(String(req.algo || "sha256"), key) + .update(data) + .digest(enc), + }; + } + default: + throw new Error("unsupported crypto op"); + } +} +/** + * Real HTTP via the host's global fetch, bounded by a timeout + body-size cap. + * Policy: http(s) URLs only (no file:/ftp:/data: etc.), and `credentials: 'omit'` + * so a (potentially shared) rule cannot ride the user's ambient cookies/sessions. + */ +async function hostFetchOp(req) { + const hostFetch = globalThis.fetch; + if (typeof hostFetch !== "function") { + throw new Error("fetch is not available in this environment"); + } + let parsedUrl; + try { + parsedUrl = new URL(String(req.url)); + } + catch (_a) { + throw new Error("Invalid URL"); + } + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + throw new Error("Only http and https URLs are allowed in sandboxed rules"); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const resp = await hostFetch(parsedUrl.toString(), { + method: req.method || "GET", + headers: req.headers || {}, + body: req.body, + signal: controller.signal, + credentials: "omit", + }); + const buf = await resp.arrayBuffer(); + if (buf.byteLength > MAX_FETCH_BODY_BYTES) { + throw new Error("response body exceeds sandbox size limit"); + } + const headers = {}; + resp.headers.forEach((v, k) => { + headers[k] = v; + }); + return { + status: resp.status, + statusText: resp.statusText, + ok: resp.ok, + url: resp.url, + headers, + body: Buffer.from(buf).toString("utf8"), + }; + } + finally { + clearTimeout(timer); + } +} /* Expects that `functionString` has already been validated via isValidFunctionString. */ async function executeUserFunction(ctx, functionString, args) { var _a, _b, _c, _d, _e; @@ -301,7 +196,7 @@ async function executeUserFunction(ctx, functionString, args) { 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)); + vm.runtime.setInterruptHandler((0, quickjs_emscripten_core_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); @@ -309,11 +204,56 @@ async function executeUserFunction(ctx, functionString, args) { const sharedHandle = vm.newString(sharedStateJson); vm.setProp(vm.global, "__sharedStateJson", sharedHandle); sharedHandle.dispose(); + // In-flight async host calls (fetch) the pump loop must await before the + // guest's await-chain can progress. + const inflight = []; + // crypto bridge — SYNC: a JSON string in, a JSON string out. + const cryptoFn = vm.newFunction("__hostCrypto", (reqHandle) => { + let out; + try { + out = JSON.stringify(hostCryptoOp(JSON.parse(vm.getString(reqHandle)))); + } + catch (e) { + out = JSON.stringify({ error: String((e && e.message) || e) }); + } + return vm.newString(out); + }); + vm.setProp(vm.global, "__hostCrypto", cryptoFn); + cryptoFn.dispose(); + // fetch bridge — ASYNC via guest promise: return a pending guest Promise now, + // resolve it with the copied response once the real host fetch settles. The + // resolve is guarded so a late settle after a timeout/dispose can't throw. + const fetchFn = vm.newFunction("__hostFetch", (reqHandle) => { + const req = JSON.parse(vm.getString(reqHandle)); + const deferred = vm.newPromise(); + inflight.push((async () => { + let payload; + try { + payload = JSON.stringify(await hostFetchOp(req)); + } + catch (e) { + payload = JSON.stringify({ __fetchError: String((e && e.message) || e) }); + } + try { + const h = vm.newString(payload); + deferred.resolve(h); + h.dispose(); + } + catch (_a) { + /* context disposed (overall timeout) — drop the result */ + } + })()); + return deferred.handle; + }); + vm.setProp(vm.global, "__hostFetch", fetchFn); + fetchFn.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_POLYFILLS + - SANDBOX_SETUP + + const program = sandbox_globals_1.SANDBOX_POLYFILLS + + sandbox_globals_1.SANDBOX_BRIDGE_SHIMS + + sandbox_globals_1.SANDBOX_EXTRA_SHIMS + + sandbox_globals_1.SANDBOX_SETUP + "Promise.resolve((" + functionString + "\n)(args)).then(function (r) {" + @@ -333,17 +273,37 @@ async function executeUserFunction(ctx, functionString, args) { } // 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. - // On a job error / deadline interrupt the result carries a QuickJSHandle; - // dispose it eagerly (vm.dispose() in finally would reclaim it too). - const jobs = vm.runtime.executePendingJobs(); - if (jobs.error) - jobs.error.dispose(); - const outHandle = vm.getProp(vm.global, "__OUTPUT"); - const output = vm.dump(outHandle); - outHandle.dispose(); + // Pump loop — drive the user fn's promise chain, including real async host + // I/O (fetch). Re-arm the per-step CPU interrupt each iteration so a slow + // network wait doesn't make a post-fetch sync burst trip the original + // deadline; the overall wall-clock cap bounds total time. Repeat until the + // top-level promise sets __OUTPUT, the deadline trips, or nothing is pending. + const overallDeadline = Date.now() + OVERALL_TIMEOUT_MS; + let output; + for (;;) { + vm.runtime.setInterruptHandler((0, quickjs_emscripten_core_1.shouldInterruptAfterDeadline)(Date.now() + EXEC_TIMEOUT_MS)); + // On a job error / deadline interrupt the result carries a QuickJSHandle; + // dispose it eagerly (vm.dispose() in finally would reclaim it too). + const jobs = vm.runtime.executePendingJobs(); + if (jobs.error) + jobs.error.dispose(); + const outHandle = vm.getProp(vm.global, "__OUTPUT"); + output = vm.dump(outHandle); + outHandle.dispose(); + if (typeof output === "string") + break; // settled + if (Date.now() > overallDeadline) + break; // timed out + if (inflight.length === 0) + break; // nothing pending → chain won't progress + const batch = inflight.splice(0); + await Promise.race([ + Promise.allSettled(batch), + new Promise((r) => setTimeout(r, Math.max(0, overallDeadline - Date.now()))), + ]); + } if (typeof output !== "string") { - // Promise never settled (e.g. unsupported real async) → no modification. + // Promise never settled (timeout / never-resolving await) → no modification. return undefined; } let parsed; diff --git a/dist/utils/sandbox-globals.d.ts b/dist/utils/sandbox-globals.d.ts new file mode 100644 index 0000000..0e6f913 --- /dev/null +++ b/dist/utils/sandbox-globals.d.ts @@ -0,0 +1,14 @@ +/** + * sandbox-globals — the JavaScript SOURCE that runs INSIDE the QuickJS guest realm + * (split out of utils/index.ts for readability/debuggability). These are plain + * strings injected into the sandbox; nothing here executes in the host. index.ts + * owns the host side (module/context lifecycle, the crypto/fetch bridges, the + * pump loop). Three blocks, concatenated in this order by executeUserFunction: + * SANDBOX_POLYFILLS — pure-JS web/Node global shims (URL, encoding, clone…) + * SANDBOX_BRIDGE_SHIMS — guest halves of the host bridges (crypto, fetch, require) + * SANDBOX_SETUP — console/atob/btoa + args/$sharedState/__OUTPUT wiring + */ +export declare const SANDBOX_SETUP: string; +export declare const SANDBOX_POLYFILLS: string; +export declare const SANDBOX_BRIDGE_SHIMS: string; +export declare const SANDBOX_EXTRA_SHIMS: string; diff --git a/dist/utils/sandbox-globals.js b/dist/utils/sandbox-globals.js new file mode 100644 index 0000000..e08e732 --- /dev/null +++ b/dist/utils/sandbox-globals.js @@ -0,0 +1,445 @@ +"use strict"; +/** + * sandbox-globals — the JavaScript SOURCE that runs INSIDE the QuickJS guest realm + * (split out of utils/index.ts for readability/debuggability). These are plain + * strings injected into the sandbox; nothing here executes in the host. index.ts + * owns the host side (module/context lifecycle, the crypto/fetch bridges, the + * pump loop). Three blocks, concatenated in this order by executeUserFunction: + * SANDBOX_POLYFILLS — pure-JS web/Node global shims (URL, encoding, clone…) + * SANDBOX_BRIDGE_SHIMS — guest halves of the host bridges (crypto, fetch, require) + * SANDBOX_SETUP — console/atob/btoa + args/$sharedState/__OUTPUT wiring + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SANDBOX_EXTRA_SHIMS = exports.SANDBOX_BRIDGE_SHIMS = exports.SANDBOX_POLYFILLS = exports.SANDBOX_SETUP = void 0; +// 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. +exports.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(""); +// Pure-JS polyfills for common Web/Node globals that QuickJS (a bare ECMAScript +// engine) does not provide. All implemented INSIDE the sandbox using only QuickJS +// built-ins — no host object crosses the boundary, so they add no escape surface +// (same safety model as atob/btoa). `String.raw` keeps regex backslashes literal. +// NOTE: `crypto` and `fetch` are NOT here — they need real host capabilities and +// live in SANDBOX_BRIDGE_SHIMS (the guest halves of the host bridges). +exports.SANDBOX_POLYFILLS = String.raw ` +(function (g) { + "use strict"; + + // ---- URLSearchParams ---- + function URLSearchParams(init) { + this.__l = []; + var self = this; + if (init == null || init === "") { /* empty */ } + else if (typeof init === "string") { + var s = init.charAt(0) === "?" ? init.slice(1) : init; + if (s.length) s.split("&").forEach(function (pair) { + if (pair === "") return; + var idx = pair.indexOf("="); + var k = idx === -1 ? pair : pair.slice(0, idx); + var v = idx === -1 ? "" : pair.slice(idx + 1); + self.__l.push([decodeURIComponent(k.replace(/\+/g, " ")), decodeURIComponent(v.replace(/\+/g, " "))]); + }); + } else if (init instanceof Array) { + init.forEach(function (p) { self.__l.push([String(p[0]), String(p[1])]); }); + } else if (typeof init.forEach === "function") { + init.forEach(function (v, k) { self.__l.push([String(k), String(v)]); }); + } else if (typeof init === "object") { + for (var key in init) if (Object.prototype.hasOwnProperty.call(init, key)) self.__l.push([key, String(init[key])]); + } + } + URLSearchParams.prototype.append = function (k, v) { this.__l.push([String(k), String(v)]); }; + URLSearchParams.prototype["delete"] = function (k) { k = String(k); this.__l = this.__l.filter(function (e) { return e[0] !== k; }); }; + URLSearchParams.prototype.get = function (k) { k = String(k); for (var i = 0; i < this.__l.length; i++) if (this.__l[i][0] === k) return this.__l[i][1]; return null; }; + URLSearchParams.prototype.getAll = function (k) { k = String(k); var r = []; for (var i = 0; i < this.__l.length; i++) if (this.__l[i][0] === k) r.push(this.__l[i][1]); return r; }; + URLSearchParams.prototype.has = function (k) { return this.get(String(k)) !== null; }; + URLSearchParams.prototype.set = function (k, v) { + k = String(k); v = String(v); var found = false; var out = []; + for (var i = 0; i < this.__l.length; i++) { + if (this.__l[i][0] === k) { if (!found) { out.push([k, v]); found = true; } } + else out.push(this.__l[i]); + } + if (!found) out.push([k, v]); this.__l = out; + }; + URLSearchParams.prototype.forEach = function (cb, t) { for (var i = 0; i < this.__l.length; i++) cb.call(t, this.__l[i][1], this.__l[i][0], this); }; + URLSearchParams.prototype.keys = function () { return this.__l.map(function (e) { return e[0]; }); }; + URLSearchParams.prototype.values = function () { return this.__l.map(function (e) { return e[1]; }); }; + URLSearchParams.prototype.entries = function () { return this.__l.map(function (e) { return [e[0], e[1]]; }); }; + URLSearchParams.prototype.sort = function () { this.__l.sort(function (a, b) { return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0; }); }; + URLSearchParams.prototype.toString = function () { return this.__l.map(function (e) { return encodeURIComponent(e[0]) + "=" + encodeURIComponent(e[1]); }).join("&"); }; + + // ---- URL ---- + var URL_RE = /^(?:([^:/?#]+):)?(?:\/\/(?:([^/?#@]*)@)?([^/?#:]*)(?::(\d+))?)?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/; + function URL(url, base) { + url = String(url); + if (base != null && !/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(url)) { + var b = new URL(String(base)); + if (url.indexOf("//") === 0) url = b.protocol + url; + else if (url.charAt(0) === "/") url = b.protocol + "//" + b.host + url; + else if (url.charAt(0) === "?") url = b.protocol + "//" + b.host + b.pathname + url; + else if (url.charAt(0) === "#") url = b.protocol + "//" + b.host + b.pathname + b.search + url; + else url = b.protocol + "//" + b.host + b.pathname.replace(/[^/]*$/, "") + url; + } + var m = url.match(URL_RE); + if (!m || !m[1]) throw new TypeError("Invalid URL: " + url); + this.protocol = m[1].toLowerCase() + ":"; + var auth = m[2] || "", ai = auth.indexOf(":"); + this.username = ai === -1 ? auth : auth.slice(0, ai); + this.password = ai === -1 ? "" : auth.slice(ai + 1); + this.hostname = (m[3] || "").toLowerCase(); + this.port = m[4] || ""; + this.host = this.hostname + (this.port ? ":" + this.port : ""); + this.pathname = m[5] || (this.hostname ? "/" : ""); + this.search = (m[6] != null && m[6] !== "") ? "?" + m[6] : ""; + this.hash = (m[7] != null && m[7] !== "") ? "#" + m[7] : ""; + this.searchParams = new URLSearchParams(this.search); + var sp = this.protocol; + var special = sp === "http:" || sp === "https:" || sp === "ftp:" || sp === "ws:" || sp === "wss:"; + this.origin = (special && this.hostname) ? (this.protocol + "//" + this.host) : "null"; + } + Object.defineProperty(URL.prototype, "href", { + get: function () { + var auth = this.username ? (this.username + (this.password ? ":" + this.password : "") + "@") : ""; + var search = this.searchParams && this.searchParams.toString ? this.searchParams.toString() : ""; + search = search ? "?" + search : ""; + var hostPart = this.host ? ("//" + auth + this.host) : (this.protocol === "file:" ? "//" : ""); + return this.protocol + hostPart + this.pathname + search + this.hash; + }, + set: function (v) { URL.call(this, v); } + }); + URL.prototype.toString = function () { return this.href; }; + URL.prototype.toJSON = function () { return this.href; }; + + // ---- TextEncoder / TextDecoder (UTF-8) ---- + function TextEncoder() {} + TextEncoder.prototype.encode = function (str) { + str = String(str === undefined ? "" : str); + var out = []; + for (var i = 0; i < str.length; i++) { + var c = str.charCodeAt(i); + if (c < 0x80) out.push(c); + else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); + else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) { + var c2 = str.charCodeAt(i + 1); + if (c2 >= 0xdc00 && c2 <= 0xdfff) { + var cp = 0x10000 + ((c - 0xd800) << 10) + (c2 - 0xdc00); + out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + i++; + } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } + return new Uint8Array(out); + }; + function TextDecoder() {} + TextDecoder.prototype.decode = function (buf) { + if (!buf) return ""; + var bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf.buffer || buf); + var out = "", i = 0; + while (i < bytes.length) { + var b = bytes[i++]; + if (b < 0x80) out += String.fromCharCode(b); + else if (b >= 0xc0 && b < 0xe0) out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f)); + else if (b >= 0xe0 && b < 0xf0) out += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f)); + else { + var cp2 = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f); + cp2 -= 0x10000; + out += String.fromCharCode(0xd800 + (cp2 >> 10), 0xdc00 + (cp2 & 0x3ff)); + } + } + return out; + }; + + // ---- structuredClone ---- + function structuredClone(value) { + function cl(x, seen) { + if (x === null || typeof x !== "object") return x; + if (seen.has(x)) return seen.get(x); + if (x instanceof Date) return new Date(x.getTime()); + if (x instanceof RegExp) return new RegExp(x.source, x.flags); + var out; + if (Array.isArray(x)) { out = []; seen.set(x, out); for (var i = 0; i < x.length; i++) out[i] = cl(x[i], seen); return out; } + if (x instanceof Map) { out = new Map(); seen.set(x, out); x.forEach(function (v, k) { out.set(cl(k, seen), cl(v, seen)); }); return out; } + if (x instanceof Set) { out = new Set(); seen.set(x, out); x.forEach(function (v) { out.add(cl(v, seen)); }); return out; } + out = {}; seen.set(x, out); + for (var key in x) if (Object.prototype.hasOwnProperty.call(x, key)) out[key] = cl(x[key], seen); + return out; + } + return cl(value, new Map()); + } + + g.URLSearchParams = URLSearchParams; + g.URL = URL; + g.TextEncoder = TextEncoder; + g.TextDecoder = TextDecoder; + g.structuredClone = structuredClone; +})(typeof globalThis !== "undefined" ? globalThis : this); +`; +// In-guest shims for the HOST-BRIDGED capabilities (crypto, fetch). These call +// the host functions __hostCrypto (sync) / __hostFetch (async) that +// executeUserFunction installs. Only JSON strings cross the boundary — the host +// handlers receive a string and return a string (fetch via a guest promise the +// host resolves), so no host object is ever exposed to the rule code. +exports.SANDBOX_BRIDGE_SHIMS = String.raw ` +(function (g) { + "use strict"; + + // ---- crypto (real entropy/digests via the host's node:crypto) ---- + g.crypto = { + randomUUID: function () { + return JSON.parse(__hostCrypto(JSON.stringify({ op: "randomUUID" }))).uuid; + }, + getRandomValues: function (arr) { + var r = JSON.parse(__hostCrypto(JSON.stringify({ op: "randomBytes", size: arr.length }))); + for (var i = 0; i < arr.length; i++) arr[i] = r.bytes[i]; + return arr; + } + }; + // node:crypto subset reachable via require('crypto') + var nodeCrypto = { + randomUUID: g.crypto.randomUUID, + randomBytes: function (n) { + var r = JSON.parse(__hostCrypto(JSON.stringify({ op: "randomBytes", size: n }))); + return r.bytes; + }, + createHash: function (algo) { + var buf = ""; + return { + update: function (d) { buf += String(d); return this; }, + digest: function (enc) { + return JSON.parse(__hostCrypto(JSON.stringify({ op: "hash", algo: algo, data: buf, encoding: enc || "hex" }))).digest; + } + }; + } + }; + + // ---- require() — safe modules map to bridges; everything else is a guided error ---- + g.require = function (name) { + if (name === "crypto" || name === "node:crypto") return nodeCrypto; + throw new Error("Cannot require('" + name + "') — modules are not available in sandboxed rules"); + }; + + // ---- fetch (real HTTP via the host; copied request/response only) ---- + function Headers(obj) { + this.__h = {}; + for (var k in (obj || {})) if (Object.prototype.hasOwnProperty.call(obj, k)) this.__h[String(k).toLowerCase()] = obj[k]; + } + Headers.prototype.get = function (k) { var v = this.__h[String(k).toLowerCase()]; return v == null ? null : v; }; + Headers.prototype.has = function (k) { return String(k).toLowerCase() in this.__h; }; + Headers.prototype.forEach = function (cb, t) { for (var k in this.__h) cb.call(t, this.__h[k], k, this); }; + g.Headers = Headers; + + g.fetch = function (url, init) { + init = init || {}; + var hdrs = init.headers || {}; + if (hdrs && typeof hdrs.forEach === "function" && hdrs.__h) { var o = {}; hdrs.forEach(function (v, k) { o[k] = v; }); hdrs = o; } + var req = JSON.stringify({ + url: String(url), + method: init.method || "GET", + headers: hdrs, + body: init.body != null ? String(init.body) : undefined + }); + return __hostFetch(req).then(function (jsonStr) { + var d = JSON.parse(jsonStr); + if (d && d.__fetchError) throw new Error(d.__fetchError); + return { + status: d.status, statusText: d.statusText, ok: d.ok, url: d.url, + headers: new Headers(d.headers), + text: function () { return Promise.resolve(d.body); }, + json: function () { return Promise.resolve(JSON.parse(d.body)); } + }; + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); +`; +// Additional Web/Node APIs layered on top of the polyfills + bridges. Pure-JS +// where possible (Buffer, timers, Blob, FormData, Request/Response, performance); +// XHR rides the fetch bridge; createHmac/subtle.digest ride the crypto bridge. +// MUST be concatenated AFTER SANDBOX_POLYFILLS + SANDBOX_BRIDGE_SHIMS (uses +// TextEncoder/Headers/URLSearchParams, fetch, __hostCrypto, atob/btoa). +exports.SANDBOX_EXTRA_SHIMS = String.raw ` +(function (g) { + "use strict"; + var _hex = "0123456789abcdef"; + function _u8(s){ return Array.prototype.slice.call(new TextEncoder().encode(String(s))); } + function _s8(b){ return new TextDecoder().decode(new Uint8Array(b)); } + function _toHex(b){ var o=""; for(var i=0;i>4)&15]+_hex[b[i]&15]; } return o; } + function _fromHex(s){ s=String(s); var o=[]; for(var i=0;i+1=200&&this.status<300; this.headers=new g.Headers(init.headers||{}); this.__isResponse=true; } + Response.prototype.text=function(){ return Promise.resolve(this.__body); }; + Response.prototype.json=function(){ var b=this.__body; return Promise.resolve(JSON.parse(b)); }; + Response.prototype.arrayBuffer=function(){ return Promise.resolve(new Uint8Array(_u8(this.__body)).buffer); }; + g.Response = Response; + + // ---- fetch augmentation: Request input + FormData/Blob/URLSearchParams bodies ---- + var __origFetch = g.fetch; + function _multipart(fd){ + var boundary = "----RQFormBoundary" + crypto.randomUUID().replace(/-/g,""); + var CRLF = "\r\n", body = ""; + fd.__e.forEach(function(e){ var name=e[0], val=e[1], fn=e[2]; + body += "--"+boundary+CRLF; + if(val && val.__isBlob){ body += 'Content-Disposition: form-data; name="'+name+'"'+(fn?'; filename="'+fn+'"':'')+CRLF; if(val.type) body+="Content-Type: "+val.type+CRLF; body+=CRLF+_s8(val.__bytes)+CRLF; } + else { body += 'Content-Disposition: form-data; name="'+name+'"'+CRLF+CRLF+String(val)+CRLF; } + }); + body += "--"+boundary+"--"+CRLF; + return { body: body, contentType: "multipart/form-data; boundary="+boundary }; + } + g.fetch = function(input, init){ + init = init || {}; + if (input instanceof Request){ init={ method: input.method, headers: input.headers, body: input.body }; input = input.url; } + var body = init.body, headers = init.headers || {}; + if (body && body.__isBlob){ body = _s8(body.__bytes); } + else if (body instanceof FormData){ var mp=_multipart(body); body=mp.body; var o={}; if(headers&&headers.forEach){ headers.forEach(function(v,k){o[k]=v;}); } else { for(var k in headers) o[k]=headers[k]; } o["content-type"]=mp.contentType; headers=o; } + else if (body instanceof g.URLSearchParams){ body=body.toString(); var o2={}; for(var k2 in headers) o2[k2]=headers[k2]; if(!o2["content-type"]&&!o2["Content-Type"]) o2["content-type"]="application/x-www-form-urlencoded"; headers=o2; } + var init2={}; for(var kk in init) init2[kk]=init[kk]; init2.body=body; init2.headers=headers; + return __origFetch(input, init2); + }; + + // ---- XMLHttpRequest (async only; sync throws) over the fetch bridge ---- + function XMLHttpRequest(){ this.readyState=0; this.status=0; this.statusText=""; this.responseText=""; this.response=""; this.responseType=""; this._h={}; this._m="GET"; this._u=""; this._rh={}; this.onreadystatechange=null; this.onload=null; this.onerror=null; this.onloadend=null; } + XMLHttpRequest.UNSENT=0; XMLHttpRequest.OPENED=1; XMLHttpRequest.HEADERS_RECEIVED=2; XMLHttpRequest.LOADING=3; XMLHttpRequest.DONE=4; + XMLHttpRequest.prototype.open=function(method,url,async){ if(async===false) throw new Error("Synchronous XMLHttpRequest is not supported in sandboxed rules; use async or fetch()."); this._m=method||"GET"; this._u=String(url); this.readyState=1; if(this.onreadystatechange) this.onreadystatechange(); }; + XMLHttpRequest.prototype.setRequestHeader=function(k,v){ this._h[k]=v; }; + XMLHttpRequest.prototype.getAllResponseHeaders=function(){ var s=""; for(var k in this._rh) s+=k+": "+this._rh[k]+"\r\n"; return s; }; + XMLHttpRequest.prototype.getResponseHeader=function(k){ k=String(k).toLowerCase(); return (k in this._rh)?this._rh[k]:null; }; + XMLHttpRequest.prototype.abort=function(){}; + XMLHttpRequest.prototype.send=function(body){ var self=this; + g.fetch(this._u,{ method:this._m, headers:this._h, body:body }).then(function(res){ self.status=res.status; self.statusText=res.statusText||""; self._rh={}; if(res.headers&&res.headers.forEach) res.headers.forEach(function(v,k){ self._rh[String(k).toLowerCase()]=v; }); return res.text(); }) + .then(function(text){ self.responseText=text; self.response=(self.responseType==="json")?(function(){try{return JSON.parse(text);}catch(e){return null;}})():text; self.readyState=4; if(self.onreadystatechange) self.onreadystatechange(); if(self.onload) self.onload(); if(self.onloadend) self.onloadend(); }) + .catch(function(e){ self.readyState=4; if(self.onreadystatechange) self.onreadystatechange(); if(self.onerror) self.onerror(e); if(self.onloadend) self.onloadend(); }); + }; + g.XMLHttpRequest = XMLHttpRequest; + + // ---- WebSocket: unsupported (a persistent connection can't outlive a per-request execution) ---- + g.WebSocket = function(){ throw new Error("WebSocket is not available in sandboxed rules (no persistent connections)."); }; + + // ---- crypto: createHmac (node) + subtle.digest (webcrypto) ---- + var __nc = (function(){ try { return g.require("crypto"); } catch(e){ return null; } })(); + if (__nc){ + __nc.createHmac = function(algo, key){ var buf=""; + return { update:function(d){ buf+=String(d); return this; }, digest:function(enc){ return JSON.parse(__hostCrypto(JSON.stringify({op:"hmac",algo:algo,key:String(key),data:buf,encoding:enc||"hex"}))).digest; } }; + }; + } + if (g.crypto && !g.crypto.subtle){ + g.crypto.subtle = { + digest: function(algo, data){ + var name=(typeof algo==="string"?algo:(algo&&algo.name)||"SHA-256").toLowerCase().replace("-",""); + var bytes; + if(typeof data==="string") bytes=_u8(data); + else if(data instanceof ArrayBuffer) bytes=Array.prototype.slice.call(new Uint8Array(data)); + else if(data && data.buffer) bytes=Array.prototype.slice.call(new Uint8Array(data.buffer, data.byteOffset||0, data.byteLength)); + else bytes=Array.prototype.slice.call(data||[]); + var hex=JSON.parse(__hostCrypto(JSON.stringify({op:"hash",algo:name,data:_toB64(bytes),dataEncoding:"base64",encoding:"hex"}))).digest; + return Promise.resolve(new Uint8Array(_fromHex(hex)).buffer); + } + }; + } +})(typeof globalThis !== "undefined" ? globalThis : this); +`; diff --git a/package-lock.json b/package-lock.json index 6b20a39..bc187c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "mime-types": "^2.1.35", "mkdirp": "^0.5.5", "node-forge": "^1.3.0", - "quickjs-emscripten": "^0.32.0", + "quickjs-emscripten-core": "^0.32.0", "semaphore": "^1.1.0", "ua-parser-js": "^1.0.37", "url": "^0.11.3", @@ -102,42 +102,6 @@ "@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", @@ -3945,22 +3909,6 @@ } ] }, - "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", diff --git a/package.json b/package.json index 00e8c3e..7e7a0f6 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "mime-types": "^2.1.35", "mkdirp": "^0.5.5", "node-forge": "^1.3.0", - "quickjs-emscripten": "^0.32.0", + "quickjs-emscripten-core": "^0.32.0", "semaphore": "^1.1.0", "ua-parser-js": "^1.0.37", "url": "^0.11.3", diff --git a/src/utils/index.ts b/src/utils/index.ts index 891f551..0947572 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,10 +1,22 @@ import variant from "@jitl/quickjs-singlefile-cjs-release-sync"; +// Import from quickjs-emscripten-core (lean, bring-your-own-variant) rather than +// the umbrella `quickjs-emscripten`: the umbrella's auto-loader statically +// references every WASM variant package, which a bundler (the desktop's webpack) +// tries to resolve and fails on. core + our single embedded variant is +// bundler-safe. (Same dependency choice as @requestly/sandbox-node.) import { newQuickJSWASMModuleFromVariant, shouldInterruptAfterDeadline, QuickJSWASMModule, -} from "quickjs-emscripten"; +} from "quickjs-emscripten-core"; +import { randomUUID, randomBytes, createHash, createHmac } from "crypto"; import GlobalStateProvider from "../components/proxy-middleware/middlewares/state"; +import { + SANDBOX_SETUP, + SANDBOX_POLYFILLS, + SANDBOX_BRIDGE_SHIMS, + SANDBOX_EXTRA_SHIMS, +} from "./sandbox-globals"; /** * RQ-2426: rule-supplied "code" rules (Modify Request/Response) used to be run @@ -30,12 +42,23 @@ import GlobalStateProvider from "../components/proxy-middleware/middlewares/stat * 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.) + * back. + * + * Web-API compatibility (so existing rule scripts don't break): `URL`, + * `URLSearchParams`, `TextEncoder`/`TextDecoder`, `structuredClone`, `atob`/`btoa` + * are pure in-guest JS shims (no host contact). `crypto` and `fetch` are HOST + * BRIDGES — the guest calls a host function that does the real work with COPIED + * data and returns copied data; no host object ever crosses the boundary, so the + * isolation guarantee is unchanged (see __hostCrypto/__hostFetch below). `fetch` + * uses the guest-promise + pump-loop pattern (works on the sync QuickJS variant; + * avoids the asyncify teardown race). `require('crypto')` maps to the same bridge; + * any other `require(...)` throws a guided error (fs/process/etc. stay absent). */ -const EXEC_TIMEOUT_MS = 5000; +const EXEC_TIMEOUT_MS = 5000; // per-step CPU/interrupt deadline (sync guest bursts) +const OVERALL_TIMEOUT_MS = 15000; // wall-clock cap incl. async host I/O (fetch) +const FETCH_TIMEOUT_MS = 10000; // per fetch() call +const MAX_FETCH_BODY_BYTES = 25 * 1024 * 1024; const MEMORY_LIMIT_BYTES = 128 * 1024 * 1024; const MAX_STACK_BYTES = 2 * 1024 * 1024; @@ -49,215 +72,6 @@ function getQuickJSModule(): Promise { 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(""); - -// Pure-JS polyfills for common Web/Node globals that QuickJS (a bare ECMAScript -// engine) does not provide. All implemented INSIDE the sandbox using only QuickJS -// built-ins — no host object crosses the boundary, so they add no escape surface -// (same safety model as atob/btoa). `String.raw` keeps regex backslashes literal. -// NOTE: `crypto` here is NOT cryptographically secure (Math.random-based) — it is -// only for id/random generation; secure RNG / crypto.subtle would need a host bridge. -const SANDBOX_POLYFILLS = String.raw` -(function (g) { - "use strict"; - - // ---- URLSearchParams ---- - function URLSearchParams(init) { - this.__l = []; - var self = this; - if (init == null || init === "") { /* empty */ } - else if (typeof init === "string") { - var s = init.charAt(0) === "?" ? init.slice(1) : init; - if (s.length) s.split("&").forEach(function (pair) { - if (pair === "") return; - var idx = pair.indexOf("="); - var k = idx === -1 ? pair : pair.slice(0, idx); - var v = idx === -1 ? "" : pair.slice(idx + 1); - self.__l.push([decodeURIComponent(k.replace(/\+/g, " ")), decodeURIComponent(v.replace(/\+/g, " "))]); - }); - } else if (init instanceof Array) { - init.forEach(function (p) { self.__l.push([String(p[0]), String(p[1])]); }); - } else if (typeof init.forEach === "function") { - init.forEach(function (v, k) { self.__l.push([String(k), String(v)]); }); - } else if (typeof init === "object") { - for (var key in init) if (Object.prototype.hasOwnProperty.call(init, key)) self.__l.push([key, String(init[key])]); - } - } - URLSearchParams.prototype.append = function (k, v) { this.__l.push([String(k), String(v)]); }; - URLSearchParams.prototype["delete"] = function (k) { k = String(k); this.__l = this.__l.filter(function (e) { return e[0] !== k; }); }; - URLSearchParams.prototype.get = function (k) { k = String(k); for (var i = 0; i < this.__l.length; i++) if (this.__l[i][0] === k) return this.__l[i][1]; return null; }; - URLSearchParams.prototype.getAll = function (k) { k = String(k); var r = []; for (var i = 0; i < this.__l.length; i++) if (this.__l[i][0] === k) r.push(this.__l[i][1]); return r; }; - URLSearchParams.prototype.has = function (k) { return this.get(String(k)) !== null; }; - URLSearchParams.prototype.set = function (k, v) { - k = String(k); v = String(v); var found = false; var out = []; - for (var i = 0; i < this.__l.length; i++) { - if (this.__l[i][0] === k) { if (!found) { out.push([k, v]); found = true; } } - else out.push(this.__l[i]); - } - if (!found) out.push([k, v]); this.__l = out; - }; - URLSearchParams.prototype.forEach = function (cb, t) { for (var i = 0; i < this.__l.length; i++) cb.call(t, this.__l[i][1], this.__l[i][0], this); }; - URLSearchParams.prototype.keys = function () { return this.__l.map(function (e) { return e[0]; }); }; - URLSearchParams.prototype.values = function () { return this.__l.map(function (e) { return e[1]; }); }; - URLSearchParams.prototype.entries = function () { return this.__l.map(function (e) { return [e[0], e[1]]; }); }; - URLSearchParams.prototype.sort = function () { this.__l.sort(function (a, b) { return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0; }); }; - URLSearchParams.prototype.toString = function () { return this.__l.map(function (e) { return encodeURIComponent(e[0]) + "=" + encodeURIComponent(e[1]); }).join("&"); }; - - // ---- URL ---- - var URL_RE = /^(?:([^:/?#]+):)?(?:\/\/(?:([^/?#@]*)@)?([^/?#:]*)(?::(\d+))?)?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/; - function URL(url, base) { - url = String(url); - if (base != null && !/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(url)) { - var b = new URL(String(base)); - if (url.indexOf("//") === 0) url = b.protocol + url; - else if (url.charAt(0) === "/") url = b.protocol + "//" + b.host + url; - else if (url.charAt(0) === "?") url = b.protocol + "//" + b.host + b.pathname + url; - else if (url.charAt(0) === "#") url = b.protocol + "//" + b.host + b.pathname + b.search + url; - else url = b.protocol + "//" + b.host + b.pathname.replace(/[^/]*$/, "") + url; - } - var m = url.match(URL_RE); - if (!m || !m[1]) throw new TypeError("Invalid URL: " + url); - this.protocol = m[1].toLowerCase() + ":"; - var auth = m[2] || "", ai = auth.indexOf(":"); - this.username = ai === -1 ? auth : auth.slice(0, ai); - this.password = ai === -1 ? "" : auth.slice(ai + 1); - this.hostname = (m[3] || "").toLowerCase(); - this.port = m[4] || ""; - this.host = this.hostname + (this.port ? ":" + this.port : ""); - this.pathname = m[5] || (this.hostname ? "/" : ""); - this.search = (m[6] != null && m[6] !== "") ? "?" + m[6] : ""; - this.hash = (m[7] != null && m[7] !== "") ? "#" + m[7] : ""; - this.searchParams = new URLSearchParams(this.search); - var sp = this.protocol; - var special = sp === "http:" || sp === "https:" || sp === "ftp:" || sp === "ws:" || sp === "wss:"; - this.origin = (special && this.hostname) ? (this.protocol + "//" + this.host) : "null"; - } - Object.defineProperty(URL.prototype, "href", { - get: function () { - var auth = this.username ? (this.username + (this.password ? ":" + this.password : "") + "@") : ""; - var search = this.searchParams && this.searchParams.toString ? this.searchParams.toString() : ""; - search = search ? "?" + search : ""; - var hostPart = this.host ? ("//" + auth + this.host) : (this.protocol === "file:" ? "//" : ""); - return this.protocol + hostPart + this.pathname + search + this.hash; - }, - set: function (v) { URL.call(this, v); } - }); - URL.prototype.toString = function () { return this.href; }; - URL.prototype.toJSON = function () { return this.href; }; - - // ---- TextEncoder / TextDecoder (UTF-8) ---- - function TextEncoder() {} - TextEncoder.prototype.encode = function (str) { - str = String(str === undefined ? "" : str); - var out = []; - for (var i = 0; i < str.length; i++) { - var c = str.charCodeAt(i); - if (c < 0x80) out.push(c); - else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); - else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) { - var c2 = str.charCodeAt(i + 1); - if (c2 >= 0xdc00 && c2 <= 0xdfff) { - var cp = 0x10000 + ((c - 0xd800) << 10) + (c2 - 0xdc00); - out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); - i++; - } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); - } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); - } - return new Uint8Array(out); - }; - function TextDecoder() {} - TextDecoder.prototype.decode = function (buf) { - if (!buf) return ""; - var bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf.buffer || buf); - var out = "", i = 0; - while (i < bytes.length) { - var b = bytes[i++]; - if (b < 0x80) out += String.fromCharCode(b); - else if (b >= 0xc0 && b < 0xe0) out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f)); - else if (b >= 0xe0 && b < 0xf0) out += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f)); - else { - var cp2 = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f); - cp2 -= 0x10000; - out += String.fromCharCode(0xd800 + (cp2 >> 10), 0xdc00 + (cp2 & 0x3ff)); - } - } - return out; - }; - - // ---- structuredClone ---- - function structuredClone(value) { - function cl(x, seen) { - if (x === null || typeof x !== "object") return x; - if (seen.has(x)) return seen.get(x); - if (x instanceof Date) return new Date(x.getTime()); - if (x instanceof RegExp) return new RegExp(x.source, x.flags); - var out; - if (Array.isArray(x)) { out = []; seen.set(x, out); for (var i = 0; i < x.length; i++) out[i] = cl(x[i], seen); return out; } - if (x instanceof Map) { out = new Map(); seen.set(x, out); x.forEach(function (v, k) { out.set(cl(k, seen), cl(v, seen)); }); return out; } - if (x instanceof Set) { out = new Set(); seen.set(x, out); x.forEach(function (v) { out.add(cl(v, seen)); }); return out; } - out = {}; seen.set(x, out); - for (var key in x) if (Object.prototype.hasOwnProperty.call(x, key)) out[key] = cl(x[key], seen); - return out; - } - return cl(value, new Map()); - } - - // ---- crypto (NOT cryptographically secure — Math.random based) ---- - var crypto = { - getRandomValues: function (arr) { - var max = arr && arr.BYTES_PER_ELEMENT ? Math.pow(256, arr.BYTES_PER_ELEMENT) : 256; - for (var i = 0; i < arr.length; i++) arr[i] = Math.floor(Math.random() * max); - return arr; - }, - randomUUID: function () { - var s = ""; - for (var i = 0; i < 36; i++) { - if (i === 8 || i === 13 || i === 18 || i === 23) s += "-"; - else if (i === 14) s += "4"; - else if (i === 19) s += (8 + Math.floor(Math.random() * 4)).toString(16); - else s += Math.floor(Math.random() * 16).toString(16); - } - return s; - } - }; - - g.URLSearchParams = URLSearchParams; - g.URL = URL; - g.TextEncoder = TextEncoder; - g.TextDecoder = TextDecoder; - g.structuredClone = structuredClone; - g.crypto = crypto; -})(typeof globalThis !== "undefined" ? globalThis : this); -`; - /** * Verify a rule's code string parses WITHOUT executing it. Constructing * `new Function(body)` compiles/parses the body but never runs it (the function @@ -277,6 +91,100 @@ export const isValidFunctionString = async function ( } }; +// ── host-side bridge handlers ── only copied data crosses the boundary. + +/** Real crypto via the host's node:crypto. Input/output are plain JSON values. */ +function hostCryptoOp(req: any): any { + switch (req?.op) { + case "randomUUID": + return { uuid: randomUUID() }; + case "randomBytes": { + const n = Math.max(0, Math.min(65536, Number(req.size) | 0)); + return { bytes: Array.from(randomBytes(n)) }; + } + case "hash": { + const enc = req.encoding === "base64" ? "base64" : "hex"; + const data = Buffer.from( + String(req.data), + req.dataEncoding === "base64" ? "base64" : "utf8" + ); + return { + digest: createHash(String(req.algo || "sha256")) + .update(data) + .digest(enc as "hex" | "base64"), + }; + } + case "hmac": { + const enc = req.encoding === "base64" ? "base64" : "hex"; + const key = Buffer.from( + String(req.key), + req.keyEncoding === "base64" ? "base64" : "utf8" + ); + const data = Buffer.from( + String(req.data), + req.dataEncoding === "base64" ? "base64" : "utf8" + ); + return { + digest: createHmac(String(req.algo || "sha256"), key) + .update(data) + .digest(enc as "hex" | "base64"), + }; + } + default: + throw new Error("unsupported crypto op"); + } +} + +/** + * Real HTTP via the host's global fetch, bounded by a timeout + body-size cap. + * Policy: http(s) URLs only (no file:/ftp:/data: etc.), and `credentials: 'omit'` + * so a (potentially shared) rule cannot ride the user's ambient cookies/sessions. + */ +async function hostFetchOp(req: any): Promise { + const hostFetch: any = (globalThis as any).fetch; + if (typeof hostFetch !== "function") { + throw new Error("fetch is not available in this environment"); + } + let parsedUrl: URL; + try { + parsedUrl = new URL(String(req.url)); + } catch { + throw new Error("Invalid URL"); + } + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + throw new Error("Only http and https URLs are allowed in sandboxed rules"); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const resp = await hostFetch(parsedUrl.toString(), { + method: req.method || "GET", + headers: req.headers || {}, + body: req.body, + signal: controller.signal, + credentials: "omit", + }); + const buf = await resp.arrayBuffer(); + if (buf.byteLength > MAX_FETCH_BODY_BYTES) { + throw new Error("response body exceeds sandbox size limit"); + } + const headers: Record = {}; + resp.headers.forEach((v: string, k: string) => { + headers[k] = v; + }); + return { + status: resp.status, + statusText: resp.statusText, + ok: resp.ok, + url: resp.url, + headers, + body: Buffer.from(buf).toString("utf8"), + }; + } finally { + clearTimeout(timer); + } +} + /* Expects that `functionString` has already been validated via isValidFunctionString. */ export async function executeUserFunction( ctx: any, @@ -324,11 +232,58 @@ export async function executeUserFunction( vm.setProp(vm.global, "__sharedStateJson", sharedHandle); sharedHandle.dispose(); + // In-flight async host calls (fetch) the pump loop must await before the + // guest's await-chain can progress. + const inflight: Promise[] = []; + + // crypto bridge — SYNC: a JSON string in, a JSON string out. + const cryptoFn = vm.newFunction("__hostCrypto", (reqHandle) => { + let out: string; + try { + out = JSON.stringify(hostCryptoOp(JSON.parse(vm.getString(reqHandle)))); + } catch (e: any) { + out = JSON.stringify({ error: String((e && e.message) || e) }); + } + return vm.newString(out); + }); + vm.setProp(vm.global, "__hostCrypto", cryptoFn); + cryptoFn.dispose(); + + // fetch bridge — ASYNC via guest promise: return a pending guest Promise now, + // resolve it with the copied response once the real host fetch settles. The + // resolve is guarded so a late settle after a timeout/dispose can't throw. + const fetchFn = vm.newFunction("__hostFetch", (reqHandle) => { + const req = JSON.parse(vm.getString(reqHandle)); + const deferred = vm.newPromise(); + inflight.push( + (async () => { + let payload: string; + try { + payload = JSON.stringify(await hostFetchOp(req)); + } catch (e: any) { + payload = JSON.stringify({ __fetchError: String((e && e.message) || e) }); + } + try { + const h = vm.newString(payload); + deferred.resolve(h); + h.dispose(); + } catch { + /* context disposed (overall timeout) — drop the result */ + } + })() + ); + return deferred.handle; + }); + vm.setProp(vm.global, "__hostFetch", fetchFn); + fetchFn.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_POLYFILLS + + SANDBOX_BRIDGE_SHIMS + + SANDBOX_EXTRA_SHIMS + SANDBOX_SETUP + "Promise.resolve((" + functionString + @@ -351,18 +306,38 @@ export async function executeUserFunction( // Success variant — dispose the completion value (we read __OUTPUT instead). (evalResult as { value: { dispose(): void } }).value.dispose(); - // Resolve the user fn's (possibly async-but-IO-free) promise microtasks. - // On a job error / deadline interrupt the result carries a QuickJSHandle; - // dispose it eagerly (vm.dispose() in finally would reclaim it too). - const jobs = vm.runtime.executePendingJobs(); - if (jobs.error) jobs.error.dispose(); + // Pump loop — drive the user fn's promise chain, including real async host + // I/O (fetch). Re-arm the per-step CPU interrupt each iteration so a slow + // network wait doesn't make a post-fetch sync burst trip the original + // deadline; the overall wall-clock cap bounds total time. Repeat until the + // top-level promise sets __OUTPUT, the deadline trips, or nothing is pending. + const overallDeadline = Date.now() + OVERALL_TIMEOUT_MS; + let output: unknown; + for (;;) { + vm.runtime.setInterruptHandler( + shouldInterruptAfterDeadline(Date.now() + EXEC_TIMEOUT_MS) + ); + // On a job error / deadline interrupt the result carries a QuickJSHandle; + // dispose it eagerly (vm.dispose() in finally would reclaim it too). + const jobs = vm.runtime.executePendingJobs(); + if (jobs.error) jobs.error.dispose(); + + const outHandle = vm.getProp(vm.global, "__OUTPUT"); + output = vm.dump(outHandle); + outHandle.dispose(); - const outHandle = vm.getProp(vm.global, "__OUTPUT"); - const output = vm.dump(outHandle); - outHandle.dispose(); + if (typeof output === "string") break; // settled + if (Date.now() > overallDeadline) break; // timed out + if (inflight.length === 0) break; // nothing pending → chain won't progress + const batch = inflight.splice(0); + await Promise.race([ + Promise.allSettled(batch), + new Promise((r) => setTimeout(r, Math.max(0, overallDeadline - Date.now()))), + ]); + } if (typeof output !== "string") { - // Promise never settled (e.g. unsupported real async) → no modification. + // Promise never settled (timeout / never-resolving await) → no modification. return undefined; } diff --git a/src/utils/sandbox-globals.ts b/src/utils/sandbox-globals.ts new file mode 100644 index 0000000..9179874 --- /dev/null +++ b/src/utils/sandbox-globals.ts @@ -0,0 +1,446 @@ +/** + * sandbox-globals — the JavaScript SOURCE that runs INSIDE the QuickJS guest realm + * (split out of utils/index.ts for readability/debuggability). These are plain + * strings injected into the sandbox; nothing here executes in the host. index.ts + * owns the host side (module/context lifecycle, the crypto/fetch bridges, the + * pump loop). Three blocks, concatenated in this order by executeUserFunction: + * SANDBOX_POLYFILLS — pure-JS web/Node global shims (URL, encoding, clone…) + * SANDBOX_BRIDGE_SHIMS — guest halves of the host bridges (crypto, fetch, require) + * SANDBOX_SETUP — console/atob/btoa + args/$sharedState/__OUTPUT wiring + */ + +// 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. +export 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(""); + +// Pure-JS polyfills for common Web/Node globals that QuickJS (a bare ECMAScript +// engine) does not provide. All implemented INSIDE the sandbox using only QuickJS +// built-ins — no host object crosses the boundary, so they add no escape surface +// (same safety model as atob/btoa). `String.raw` keeps regex backslashes literal. +// NOTE: `crypto` and `fetch` are NOT here — they need real host capabilities and +// live in SANDBOX_BRIDGE_SHIMS (the guest halves of the host bridges). +export const SANDBOX_POLYFILLS = String.raw` +(function (g) { + "use strict"; + + // ---- URLSearchParams ---- + function URLSearchParams(init) { + this.__l = []; + var self = this; + if (init == null || init === "") { /* empty */ } + else if (typeof init === "string") { + var s = init.charAt(0) === "?" ? init.slice(1) : init; + if (s.length) s.split("&").forEach(function (pair) { + if (pair === "") return; + var idx = pair.indexOf("="); + var k = idx === -1 ? pair : pair.slice(0, idx); + var v = idx === -1 ? "" : pair.slice(idx + 1); + self.__l.push([decodeURIComponent(k.replace(/\+/g, " ")), decodeURIComponent(v.replace(/\+/g, " "))]); + }); + } else if (init instanceof Array) { + init.forEach(function (p) { self.__l.push([String(p[0]), String(p[1])]); }); + } else if (typeof init.forEach === "function") { + init.forEach(function (v, k) { self.__l.push([String(k), String(v)]); }); + } else if (typeof init === "object") { + for (var key in init) if (Object.prototype.hasOwnProperty.call(init, key)) self.__l.push([key, String(init[key])]); + } + } + URLSearchParams.prototype.append = function (k, v) { this.__l.push([String(k), String(v)]); }; + URLSearchParams.prototype["delete"] = function (k) { k = String(k); this.__l = this.__l.filter(function (e) { return e[0] !== k; }); }; + URLSearchParams.prototype.get = function (k) { k = String(k); for (var i = 0; i < this.__l.length; i++) if (this.__l[i][0] === k) return this.__l[i][1]; return null; }; + URLSearchParams.prototype.getAll = function (k) { k = String(k); var r = []; for (var i = 0; i < this.__l.length; i++) if (this.__l[i][0] === k) r.push(this.__l[i][1]); return r; }; + URLSearchParams.prototype.has = function (k) { return this.get(String(k)) !== null; }; + URLSearchParams.prototype.set = function (k, v) { + k = String(k); v = String(v); var found = false; var out = []; + for (var i = 0; i < this.__l.length; i++) { + if (this.__l[i][0] === k) { if (!found) { out.push([k, v]); found = true; } } + else out.push(this.__l[i]); + } + if (!found) out.push([k, v]); this.__l = out; + }; + URLSearchParams.prototype.forEach = function (cb, t) { for (var i = 0; i < this.__l.length; i++) cb.call(t, this.__l[i][1], this.__l[i][0], this); }; + URLSearchParams.prototype.keys = function () { return this.__l.map(function (e) { return e[0]; }); }; + URLSearchParams.prototype.values = function () { return this.__l.map(function (e) { return e[1]; }); }; + URLSearchParams.prototype.entries = function () { return this.__l.map(function (e) { return [e[0], e[1]]; }); }; + URLSearchParams.prototype.sort = function () { this.__l.sort(function (a, b) { return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0; }); }; + URLSearchParams.prototype.toString = function () { return this.__l.map(function (e) { return encodeURIComponent(e[0]) + "=" + encodeURIComponent(e[1]); }).join("&"); }; + + // ---- URL ---- + var URL_RE = /^(?:([^:/?#]+):)?(?:\/\/(?:([^/?#@]*)@)?([^/?#:]*)(?::(\d+))?)?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/; + function URL(url, base) { + url = String(url); + if (base != null && !/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(url)) { + var b = new URL(String(base)); + if (url.indexOf("//") === 0) url = b.protocol + url; + else if (url.charAt(0) === "/") url = b.protocol + "//" + b.host + url; + else if (url.charAt(0) === "?") url = b.protocol + "//" + b.host + b.pathname + url; + else if (url.charAt(0) === "#") url = b.protocol + "//" + b.host + b.pathname + b.search + url; + else url = b.protocol + "//" + b.host + b.pathname.replace(/[^/]*$/, "") + url; + } + var m = url.match(URL_RE); + if (!m || !m[1]) throw new TypeError("Invalid URL: " + url); + this.protocol = m[1].toLowerCase() + ":"; + var auth = m[2] || "", ai = auth.indexOf(":"); + this.username = ai === -1 ? auth : auth.slice(0, ai); + this.password = ai === -1 ? "" : auth.slice(ai + 1); + this.hostname = (m[3] || "").toLowerCase(); + this.port = m[4] || ""; + this.host = this.hostname + (this.port ? ":" + this.port : ""); + this.pathname = m[5] || (this.hostname ? "/" : ""); + this.search = (m[6] != null && m[6] !== "") ? "?" + m[6] : ""; + this.hash = (m[7] != null && m[7] !== "") ? "#" + m[7] : ""; + this.searchParams = new URLSearchParams(this.search); + var sp = this.protocol; + var special = sp === "http:" || sp === "https:" || sp === "ftp:" || sp === "ws:" || sp === "wss:"; + this.origin = (special && this.hostname) ? (this.protocol + "//" + this.host) : "null"; + } + Object.defineProperty(URL.prototype, "href", { + get: function () { + var auth = this.username ? (this.username + (this.password ? ":" + this.password : "") + "@") : ""; + var search = this.searchParams && this.searchParams.toString ? this.searchParams.toString() : ""; + search = search ? "?" + search : ""; + var hostPart = this.host ? ("//" + auth + this.host) : (this.protocol === "file:" ? "//" : ""); + return this.protocol + hostPart + this.pathname + search + this.hash; + }, + set: function (v) { URL.call(this, v); } + }); + URL.prototype.toString = function () { return this.href; }; + URL.prototype.toJSON = function () { return this.href; }; + + // ---- TextEncoder / TextDecoder (UTF-8) ---- + function TextEncoder() {} + TextEncoder.prototype.encode = function (str) { + str = String(str === undefined ? "" : str); + var out = []; + for (var i = 0; i < str.length; i++) { + var c = str.charCodeAt(i); + if (c < 0x80) out.push(c); + else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); + else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) { + var c2 = str.charCodeAt(i + 1); + if (c2 >= 0xdc00 && c2 <= 0xdfff) { + var cp = 0x10000 + ((c - 0xd800) << 10) + (c2 - 0xdc00); + out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + i++; + } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } + return new Uint8Array(out); + }; + function TextDecoder() {} + TextDecoder.prototype.decode = function (buf) { + if (!buf) return ""; + var bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf.buffer || buf); + var out = "", i = 0; + while (i < bytes.length) { + var b = bytes[i++]; + if (b < 0x80) out += String.fromCharCode(b); + else if (b >= 0xc0 && b < 0xe0) out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f)); + else if (b >= 0xe0 && b < 0xf0) out += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f)); + else { + var cp2 = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f); + cp2 -= 0x10000; + out += String.fromCharCode(0xd800 + (cp2 >> 10), 0xdc00 + (cp2 & 0x3ff)); + } + } + return out; + }; + + // ---- structuredClone ---- + function structuredClone(value) { + function cl(x, seen) { + if (x === null || typeof x !== "object") return x; + if (seen.has(x)) return seen.get(x); + if (x instanceof Date) return new Date(x.getTime()); + if (x instanceof RegExp) return new RegExp(x.source, x.flags); + var out; + if (Array.isArray(x)) { out = []; seen.set(x, out); for (var i = 0; i < x.length; i++) out[i] = cl(x[i], seen); return out; } + if (x instanceof Map) { out = new Map(); seen.set(x, out); x.forEach(function (v, k) { out.set(cl(k, seen), cl(v, seen)); }); return out; } + if (x instanceof Set) { out = new Set(); seen.set(x, out); x.forEach(function (v) { out.add(cl(v, seen)); }); return out; } + out = {}; seen.set(x, out); + for (var key in x) if (Object.prototype.hasOwnProperty.call(x, key)) out[key] = cl(x[key], seen); + return out; + } + return cl(value, new Map()); + } + + g.URLSearchParams = URLSearchParams; + g.URL = URL; + g.TextEncoder = TextEncoder; + g.TextDecoder = TextDecoder; + g.structuredClone = structuredClone; +})(typeof globalThis !== "undefined" ? globalThis : this); +`; + +// In-guest shims for the HOST-BRIDGED capabilities (crypto, fetch). These call +// the host functions __hostCrypto (sync) / __hostFetch (async) that +// executeUserFunction installs. Only JSON strings cross the boundary — the host +// handlers receive a string and return a string (fetch via a guest promise the +// host resolves), so no host object is ever exposed to the rule code. +export const SANDBOX_BRIDGE_SHIMS = String.raw` +(function (g) { + "use strict"; + + // ---- crypto (real entropy/digests via the host's node:crypto) ---- + g.crypto = { + randomUUID: function () { + return JSON.parse(__hostCrypto(JSON.stringify({ op: "randomUUID" }))).uuid; + }, + getRandomValues: function (arr) { + var r = JSON.parse(__hostCrypto(JSON.stringify({ op: "randomBytes", size: arr.length }))); + for (var i = 0; i < arr.length; i++) arr[i] = r.bytes[i]; + return arr; + } + }; + // node:crypto subset reachable via require('crypto') + var nodeCrypto = { + randomUUID: g.crypto.randomUUID, + randomBytes: function (n) { + var r = JSON.parse(__hostCrypto(JSON.stringify({ op: "randomBytes", size: n }))); + return r.bytes; + }, + createHash: function (algo) { + var buf = ""; + return { + update: function (d) { buf += String(d); return this; }, + digest: function (enc) { + return JSON.parse(__hostCrypto(JSON.stringify({ op: "hash", algo: algo, data: buf, encoding: enc || "hex" }))).digest; + } + }; + } + }; + + // ---- require() — safe modules map to bridges; everything else is a guided error ---- + g.require = function (name) { + if (name === "crypto" || name === "node:crypto") return nodeCrypto; + throw new Error("Cannot require('" + name + "') — modules are not available in sandboxed rules"); + }; + + // ---- fetch (real HTTP via the host; copied request/response only) ---- + function Headers(obj) { + this.__h = {}; + for (var k in (obj || {})) if (Object.prototype.hasOwnProperty.call(obj, k)) this.__h[String(k).toLowerCase()] = obj[k]; + } + Headers.prototype.get = function (k) { var v = this.__h[String(k).toLowerCase()]; return v == null ? null : v; }; + Headers.prototype.has = function (k) { return String(k).toLowerCase() in this.__h; }; + Headers.prototype.forEach = function (cb, t) { for (var k in this.__h) cb.call(t, this.__h[k], k, this); }; + g.Headers = Headers; + + g.fetch = function (url, init) { + init = init || {}; + var hdrs = init.headers || {}; + if (hdrs && typeof hdrs.forEach === "function" && hdrs.__h) { var o = {}; hdrs.forEach(function (v, k) { o[k] = v; }); hdrs = o; } + var req = JSON.stringify({ + url: String(url), + method: init.method || "GET", + headers: hdrs, + body: init.body != null ? String(init.body) : undefined + }); + return __hostFetch(req).then(function (jsonStr) { + var d = JSON.parse(jsonStr); + if (d && d.__fetchError) throw new Error(d.__fetchError); + return { + status: d.status, statusText: d.statusText, ok: d.ok, url: d.url, + headers: new Headers(d.headers), + text: function () { return Promise.resolve(d.body); }, + json: function () { return Promise.resolve(JSON.parse(d.body)); } + }; + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); +`; + +// Additional Web/Node APIs layered on top of the polyfills + bridges. Pure-JS +// where possible (Buffer, timers, Blob, FormData, Request/Response, performance); +// XHR rides the fetch bridge; createHmac/subtle.digest ride the crypto bridge. +// MUST be concatenated AFTER SANDBOX_POLYFILLS + SANDBOX_BRIDGE_SHIMS (uses +// TextEncoder/Headers/URLSearchParams, fetch, __hostCrypto, atob/btoa). +export const SANDBOX_EXTRA_SHIMS = String.raw` +(function (g) { + "use strict"; + var _hex = "0123456789abcdef"; + function _u8(s){ return Array.prototype.slice.call(new TextEncoder().encode(String(s))); } + function _s8(b){ return new TextDecoder().decode(new Uint8Array(b)); } + function _toHex(b){ var o=""; for(var i=0;i>4)&15]+_hex[b[i]&15]; } return o; } + function _fromHex(s){ s=String(s); var o=[]; for(var i=0;i+1=200&&this.status<300; this.headers=new g.Headers(init.headers||{}); this.__isResponse=true; } + Response.prototype.text=function(){ return Promise.resolve(this.__body); }; + Response.prototype.json=function(){ var b=this.__body; return Promise.resolve(JSON.parse(b)); }; + Response.prototype.arrayBuffer=function(){ return Promise.resolve(new Uint8Array(_u8(this.__body)).buffer); }; + g.Response = Response; + + // ---- fetch augmentation: Request input + FormData/Blob/URLSearchParams bodies ---- + var __origFetch = g.fetch; + function _multipart(fd){ + var boundary = "----RQFormBoundary" + crypto.randomUUID().replace(/-/g,""); + var CRLF = "\r\n", body = ""; + fd.__e.forEach(function(e){ var name=e[0], val=e[1], fn=e[2]; + body += "--"+boundary+CRLF; + if(val && val.__isBlob){ body += 'Content-Disposition: form-data; name="'+name+'"'+(fn?'; filename="'+fn+'"':'')+CRLF; if(val.type) body+="Content-Type: "+val.type+CRLF; body+=CRLF+_s8(val.__bytes)+CRLF; } + else { body += 'Content-Disposition: form-data; name="'+name+'"'+CRLF+CRLF+String(val)+CRLF; } + }); + body += "--"+boundary+"--"+CRLF; + return { body: body, contentType: "multipart/form-data; boundary="+boundary }; + } + g.fetch = function(input, init){ + init = init || {}; + if (input instanceof Request){ init={ method: input.method, headers: input.headers, body: input.body }; input = input.url; } + var body = init.body, headers = init.headers || {}; + if (body && body.__isBlob){ body = _s8(body.__bytes); } + else if (body instanceof FormData){ var mp=_multipart(body); body=mp.body; var o={}; if(headers&&headers.forEach){ headers.forEach(function(v,k){o[k]=v;}); } else { for(var k in headers) o[k]=headers[k]; } o["content-type"]=mp.contentType; headers=o; } + else if (body instanceof g.URLSearchParams){ body=body.toString(); var o2={}; for(var k2 in headers) o2[k2]=headers[k2]; if(!o2["content-type"]&&!o2["Content-Type"]) o2["content-type"]="application/x-www-form-urlencoded"; headers=o2; } + var init2={}; for(var kk in init) init2[kk]=init[kk]; init2.body=body; init2.headers=headers; + return __origFetch(input, init2); + }; + + // ---- XMLHttpRequest (async only; sync throws) over the fetch bridge ---- + function XMLHttpRequest(){ this.readyState=0; this.status=0; this.statusText=""; this.responseText=""; this.response=""; this.responseType=""; this._h={}; this._m="GET"; this._u=""; this._rh={}; this.onreadystatechange=null; this.onload=null; this.onerror=null; this.onloadend=null; } + XMLHttpRequest.UNSENT=0; XMLHttpRequest.OPENED=1; XMLHttpRequest.HEADERS_RECEIVED=2; XMLHttpRequest.LOADING=3; XMLHttpRequest.DONE=4; + XMLHttpRequest.prototype.open=function(method,url,async){ if(async===false) throw new Error("Synchronous XMLHttpRequest is not supported in sandboxed rules; use async or fetch()."); this._m=method||"GET"; this._u=String(url); this.readyState=1; if(this.onreadystatechange) this.onreadystatechange(); }; + XMLHttpRequest.prototype.setRequestHeader=function(k,v){ this._h[k]=v; }; + XMLHttpRequest.prototype.getAllResponseHeaders=function(){ var s=""; for(var k in this._rh) s+=k+": "+this._rh[k]+"\r\n"; return s; }; + XMLHttpRequest.prototype.getResponseHeader=function(k){ k=String(k).toLowerCase(); return (k in this._rh)?this._rh[k]:null; }; + XMLHttpRequest.prototype.abort=function(){}; + XMLHttpRequest.prototype.send=function(body){ var self=this; + g.fetch(this._u,{ method:this._m, headers:this._h, body:body }).then(function(res){ self.status=res.status; self.statusText=res.statusText||""; self._rh={}; if(res.headers&&res.headers.forEach) res.headers.forEach(function(v,k){ self._rh[String(k).toLowerCase()]=v; }); return res.text(); }) + .then(function(text){ self.responseText=text; self.response=(self.responseType==="json")?(function(){try{return JSON.parse(text);}catch(e){return null;}})():text; self.readyState=4; if(self.onreadystatechange) self.onreadystatechange(); if(self.onload) self.onload(); if(self.onloadend) self.onloadend(); }) + .catch(function(e){ self.readyState=4; if(self.onreadystatechange) self.onreadystatechange(); if(self.onerror) self.onerror(e); if(self.onloadend) self.onloadend(); }); + }; + g.XMLHttpRequest = XMLHttpRequest; + + // ---- WebSocket: unsupported (a persistent connection can't outlive a per-request execution) ---- + g.WebSocket = function(){ throw new Error("WebSocket is not available in sandboxed rules (no persistent connections)."); }; + + // ---- crypto: createHmac (node) + subtle.digest (webcrypto) ---- + var __nc = (function(){ try { return g.require("crypto"); } catch(e){ return null; } })(); + if (__nc){ + __nc.createHmac = function(algo, key){ var buf=""; + return { update:function(d){ buf+=String(d); return this; }, digest:function(enc){ return JSON.parse(__hostCrypto(JSON.stringify({op:"hmac",algo:algo,key:String(key),data:buf,encoding:enc||"hex"}))).digest; } }; + }; + } + if (g.crypto && !g.crypto.subtle){ + g.crypto.subtle = { + digest: function(algo, data){ + var name=(typeof algo==="string"?algo:(algo&&algo.name)||"SHA-256").toLowerCase().replace("-",""); + var bytes; + if(typeof data==="string") bytes=_u8(data); + else if(data instanceof ArrayBuffer) bytes=Array.prototype.slice.call(new Uint8Array(data)); + else if(data && data.buffer) bytes=Array.prototype.slice.call(new Uint8Array(data.buffer, data.byteOffset||0, data.byteLength)); + else bytes=Array.prototype.slice.call(data||[]); + var hex=JSON.parse(__hostCrypto(JSON.stringify({op:"hash",algo:name,data:_toB64(bytes),dataEncoding:"base64",encoding:"hex"}))).digest; + return Promise.resolve(new Uint8Array(_fromHex(hex)).buffer); + } + }; + } +})(typeof globalThis !== "undefined" ? globalThis : this); +`; From 99c44d5dc833a2df36a48ff0aa151c9bcdb3fe49 Mon Sep 17 00:00:00 2001 From: dinex-dev Date: Thu, 25 Jun 2026 11:46:58 +0530 Subject: [PATCH 6/9] fix(sandbox): honor real setTimeout delay (was a no-delay microtask) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setTimeout previously fired once on a microtask and ignored `ms`, silently turning retry/backoff code into a hot loop (hammering endpoints, ignoring rate limits) and breaking delay-based ordering — a quiet behavioral surprise for existing rules. Add a __hostTimer bridge: the guest setTimeout now waits the real delay via a host timer, clamped host-side to the execution's remaining wall-clock budget (so a timer can never outlast the run), driven by the existing guest-promise + pump loop. clearTimeout cancels the pending callback. setInterval stays a no-op (a repeating timer can't outlive a per-request execution); queueMicrotask stays a microtask. Chose delay-aware scheduling over throwing on setTimeout: throwing would break the common benign `setTimeout(fn, 0)` yield and library-internal timer use. Verified: 120ms timer elapses ~123ms; delay ordering (fast before slow); backoff loop accumulates real delay; clearTimeout cancels; setTimeout(0) still yields. Co-Authored-By: Claude Opus 4.8 (1M context) --- dist/utils/index.js | 33 ++++++++++++++++++++++++++++++--- dist/utils/sandbox-globals.js | 9 +++++++-- src/utils/index.ts | 34 +++++++++++++++++++++++++++++++--- src/utils/sandbox-globals.ts | 9 +++++++-- 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/dist/utils/index.js b/dist/utils/index.js index ac64647..df90e00 100644 --- a/dist/utils/index.js +++ b/dist/utils/index.js @@ -204,9 +204,12 @@ async function executeUserFunction(ctx, functionString, args) { const sharedHandle = vm.newString(sharedStateJson); vm.setProp(vm.global, "__sharedStateJson", sharedHandle); sharedHandle.dispose(); - // In-flight async host calls (fetch) the pump loop must await before the - // guest's await-chain can progress. + // In-flight async host calls (fetch, timers) the pump loop must await before + // the guest's await-chain can progress. const inflight = []; + // Wall-clock cap for the whole execution (incl. async host I/O + timer waits). + // Declared here so the timer bridge can clamp delays to the remaining budget. + const overallDeadline = Date.now() + OVERALL_TIMEOUT_MS; // crypto bridge — SYNC: a JSON string in, a JSON string out. const cryptoFn = vm.newFunction("__hostCrypto", (reqHandle) => { let out; @@ -247,6 +250,31 @@ async function executeUserFunction(ctx, functionString, args) { }); vm.setProp(vm.global, "__hostFetch", fetchFn); fetchFn.dispose(); + // timer bridge — ASYNC via guest promise: honors the real `ms` delay using a + // host timer, so setTimeout-based backoff/retry actually waits (not a no-delay + // microtask). Clamped to the remaining wall-clock budget so a timer can never + // outlast the execution; the pump loop awaits it like any in-flight host call. + const timerFn = vm.newFunction("__hostTimer", (msHandle) => { + let ms = Number(vm.dump(msHandle)); + if (!Number.isFinite(ms) || ms < 0) + ms = 0; + ms = Math.min(ms, Math.max(0, overallDeadline - Date.now())); + const deferred = vm.newPromise(); + inflight.push(new Promise((resolve) => { + setTimeout(() => { + try { + deferred.resolve(vm.undefined); + } + catch (_a) { + /* context disposed (overall timeout) — drop it */ + } + resolve(); + }, ms); + })); + return deferred.handle; + }); + vm.setProp(vm.global, "__hostTimer", timerFn); + timerFn.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. @@ -278,7 +306,6 @@ async function executeUserFunction(ctx, functionString, args) { // network wait doesn't make a post-fetch sync burst trip the original // deadline; the overall wall-clock cap bounds total time. Repeat until the // top-level promise sets __OUTPUT, the deadline trips, or nothing is pending. - const overallDeadline = Date.now() + OVERALL_TIMEOUT_MS; let output; for (;;) { vm.runtime.setInterruptHandler((0, quickjs_emscripten_core_1.shouldInterruptAfterDeadline)(Date.now() + EXEC_TIMEOUT_MS)); diff --git a/dist/utils/sandbox-globals.js b/dist/utils/sandbox-globals.js index e08e732..68676fa 100644 --- a/dist/utils/sandbox-globals.js +++ b/dist/utils/sandbox-globals.js @@ -325,10 +325,15 @@ exports.SANDBOX_EXTRA_SHIMS = String.raw ` Buffer.concat = function(list){ var all=[]; for(var i=0;i[] = []; + // Wall-clock cap for the whole execution (incl. async host I/O + timer waits). + // Declared here so the timer bridge can clamp delays to the remaining budget. + const overallDeadline = Date.now() + OVERALL_TIMEOUT_MS; // crypto bridge — SYNC: a JSON string in, a JSON string out. const cryptoFn = vm.newFunction("__hostCrypto", (reqHandle) => { @@ -277,6 +280,32 @@ export async function executeUserFunction( vm.setProp(vm.global, "__hostFetch", fetchFn); fetchFn.dispose(); + // timer bridge — ASYNC via guest promise: honors the real `ms` delay using a + // host timer, so setTimeout-based backoff/retry actually waits (not a no-delay + // microtask). Clamped to the remaining wall-clock budget so a timer can never + // outlast the execution; the pump loop awaits it like any in-flight host call. + const timerFn = vm.newFunction("__hostTimer", (msHandle) => { + let ms = Number(vm.dump(msHandle)); + if (!Number.isFinite(ms) || ms < 0) ms = 0; + ms = Math.min(ms, Math.max(0, overallDeadline - Date.now())); + const deferred = vm.newPromise(); + inflight.push( + new Promise((resolve) => { + setTimeout(() => { + try { + deferred.resolve(vm.undefined); + } catch { + /* context disposed (overall timeout) — drop it */ + } + resolve(); + }, ms); + }) + ); + return deferred.handle; + }); + vm.setProp(vm.global, "__hostTimer", timerFn); + timerFn.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. @@ -311,7 +340,6 @@ export async function executeUserFunction( // network wait doesn't make a post-fetch sync burst trip the original // deadline; the overall wall-clock cap bounds total time. Repeat until the // top-level promise sets __OUTPUT, the deadline trips, or nothing is pending. - const overallDeadline = Date.now() + OVERALL_TIMEOUT_MS; let output: unknown; for (;;) { vm.runtime.setInterruptHandler( diff --git a/src/utils/sandbox-globals.ts b/src/utils/sandbox-globals.ts index 9179874..5349c0a 100644 --- a/src/utils/sandbox-globals.ts +++ b/src/utils/sandbox-globals.ts @@ -326,10 +326,15 @@ export const SANDBOX_EXTRA_SHIMS = String.raw` Buffer.concat = function(list){ var all=[]; for(var i=0;i Date: Thu, 25 Jun 2026 11:56:36 +0530 Subject: [PATCH 7/9] refactor(sandbox): reorganize sandbox-globals by concern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regroup the in-guest source from "when it was added" (POLYFILLS/BRIDGE_SHIMS/ EXTRA_SHIMS) to "what it is", as dependency-ordered, self-describing blocks: ENCODING, BINARY, URL, HTTP_TYPES, CLONE, CRYPTO, NETWORK, TIMERS, HARNESS — composed into a single exported SANDBOX_PRELUDE (index.ts now imports one constant instead of four). - Factor the byte helpers (utf8/hex/base64) into one shared __rqb namespace instead of being trapped in the EXTRA block. - Merge the awkward fetch re-wrap (base fetch + a second wrapper via __origFetch) into a single body-aware fetch. - Round out Headers (set/append/delete). No behavior change — full capability suite (fetch + policy, crypto incl. hmac/subtle.digest, Buffer, URL, encoding, structuredClone, Blob/FormData/ Response, multipart, XHR, real-delay setTimeout, WebSocket guard, isolation, sharedState) passes identically. Co-Authored-By: Claude Opus 4.8 (1M context) --- dist/utils/index.js | 5 +- dist/utils/sandbox-globals.d.ts | 42 ++- dist/utils/sandbox-globals.js | 623 ++++++++++++++++--------------- src/utils/index.ts | 12 +- src/utils/sandbox-globals.ts | 629 +++++++++++++++++--------------- 5 files changed, 706 insertions(+), 605 deletions(-) diff --git a/dist/utils/index.js b/dist/utils/index.js index df90e00..b4e807a 100644 --- a/dist/utils/index.js +++ b/dist/utils/index.js @@ -278,10 +278,7 @@ async function executeUserFunction(ctx, functionString, args) { // 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_globals_1.SANDBOX_POLYFILLS + - sandbox_globals_1.SANDBOX_BRIDGE_SHIMS + - sandbox_globals_1.SANDBOX_EXTRA_SHIMS + - sandbox_globals_1.SANDBOX_SETUP + + const program = sandbox_globals_1.SANDBOX_PRELUDE + "Promise.resolve((" + functionString + "\n)(args)).then(function (r) {" + diff --git a/dist/utils/sandbox-globals.d.ts b/dist/utils/sandbox-globals.d.ts index 0e6f913..cd47307 100644 --- a/dist/utils/sandbox-globals.d.ts +++ b/dist/utils/sandbox-globals.d.ts @@ -1,14 +1,32 @@ /** - * sandbox-globals — the JavaScript SOURCE that runs INSIDE the QuickJS guest realm - * (split out of utils/index.ts for readability/debuggability). These are plain - * strings injected into the sandbox; nothing here executes in the host. index.ts - * owns the host side (module/context lifecycle, the crypto/fetch bridges, the - * pump loop). Three blocks, concatenated in this order by executeUserFunction: - * SANDBOX_POLYFILLS — pure-JS web/Node global shims (URL, encoding, clone…) - * SANDBOX_BRIDGE_SHIMS — guest halves of the host bridges (crypto, fetch, require) - * SANDBOX_SETUP — console/atob/btoa + args/$sharedState/__OUTPUT wiring + * sandbox-globals — the JavaScript SOURCE that runs INSIDE the QuickJS guest realm. + * + * These are plain strings injected into the sandbox; nothing here executes in the + * host. `index.ts` owns the host side (module/context lifecycle, the + * crypto/fetch/timer bridges, the pump loop). + * + * Organised by concern, each an IIFE that augments `globalThis` (except HARNESS, + * which must stay top-level so its `var`/`function` bindings are script-global). + * They are concatenated in DEPENDENCY ORDER into `SANDBOX_PRELUDE`: + * + * ENCODING atob/btoa, TextEncoder/Decoder, shared byte helpers (__rqb) + * BINARY Buffer, Blob (use __rqb) + * URL URL, URLSearchParams + * HTTP_TYPES Headers, FormData, Request, Response + * CLONE structuredClone + * CRYPTO crypto.* + require() [host bridge: __hostCrypto] + * NETWORK fetch, XMLHttpRequest, WebSocket [host bridge: __hostFetch] + * TIMERS setTimeout/setInterval/…, performance [host bridge: __hostTimer] + * HARNESS console + args/$sharedState/__OUTPUT (top-level; reads host-injected + * __argsJson/__sharedStateJson; the user fn wrapper runs after it) + * + * Security: pure shims never touch the host. The bridged blocks (CRYPTO/NETWORK/ + * TIMERS) call host functions that take and return only JSON-serialisable data — + * no host object is ever handed to the guest, so there is no escape surface. + * `String.raw` keeps regex/`\r\n` backslashes literal so they reach the sandbox JS. */ -export declare const SANDBOX_SETUP: string; -export declare const SANDBOX_POLYFILLS: string; -export declare const SANDBOX_BRIDGE_SHIMS: string; -export declare const SANDBOX_EXTRA_SHIMS: string; +/** + * The complete in-guest prelude, concatenated in dependency order. index.ts + * appends the user-function wrapper after this. + */ +export declare const SANDBOX_PRELUDE: string; diff --git a/dist/utils/sandbox-globals.js b/dist/utils/sandbox-globals.js index 68676fa..d526ccd 100644 --- a/dist/utils/sandbox-globals.js +++ b/dist/utils/sandbox-globals.js @@ -1,52 +1,179 @@ "use strict"; /** - * sandbox-globals — the JavaScript SOURCE that runs INSIDE the QuickJS guest realm - * (split out of utils/index.ts for readability/debuggability). These are plain - * strings injected into the sandbox; nothing here executes in the host. index.ts - * owns the host side (module/context lifecycle, the crypto/fetch bridges, the - * pump loop). Three blocks, concatenated in this order by executeUserFunction: - * SANDBOX_POLYFILLS — pure-JS web/Node global shims (URL, encoding, clone…) - * SANDBOX_BRIDGE_SHIMS — guest halves of the host bridges (crypto, fetch, require) - * SANDBOX_SETUP — console/atob/btoa + args/$sharedState/__OUTPUT wiring + * sandbox-globals — the JavaScript SOURCE that runs INSIDE the QuickJS guest realm. + * + * These are plain strings injected into the sandbox; nothing here executes in the + * host. `index.ts` owns the host side (module/context lifecycle, the + * crypto/fetch/timer bridges, the pump loop). + * + * Organised by concern, each an IIFE that augments `globalThis` (except HARNESS, + * which must stay top-level so its `var`/`function` bindings are script-global). + * They are concatenated in DEPENDENCY ORDER into `SANDBOX_PRELUDE`: + * + * ENCODING atob/btoa, TextEncoder/Decoder, shared byte helpers (__rqb) + * BINARY Buffer, Blob (use __rqb) + * URL URL, URLSearchParams + * HTTP_TYPES Headers, FormData, Request, Response + * CLONE structuredClone + * CRYPTO crypto.* + require() [host bridge: __hostCrypto] + * NETWORK fetch, XMLHttpRequest, WebSocket [host bridge: __hostFetch] + * TIMERS setTimeout/setInterval/…, performance [host bridge: __hostTimer] + * HARNESS console + args/$sharedState/__OUTPUT (top-level; reads host-injected + * __argsJson/__sharedStateJson; the user fn wrapper runs after it) + * + * Security: pure shims never touch the host. The bridged blocks (CRYPTO/NETWORK/ + * TIMERS) call host functions that take and return only JSON-serialisable data — + * no host object is ever handed to the guest, so there is no escape surface. + * `String.raw` keeps regex/`\r\n` backslashes literal so they reach the sandbox JS. */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.SANDBOX_EXTRA_SHIMS = exports.SANDBOX_BRIDGE_SHIMS = exports.SANDBOX_POLYFILLS = exports.SANDBOX_SETUP = void 0; -// 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. -exports.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(""); -// Pure-JS polyfills for common Web/Node globals that QuickJS (a bare ECMAScript -// engine) does not provide. All implemented INSIDE the sandbox using only QuickJS -// built-ins — no host object crosses the boundary, so they add no escape surface -// (same safety model as atob/btoa). `String.raw` keeps regex backslashes literal. -// NOTE: `crypto` and `fetch` are NOT here — they need real host capabilities and -// live in SANDBOX_BRIDGE_SHIMS (the guest halves of the host bridges). -exports.SANDBOX_POLYFILLS = String.raw ` +exports.SANDBOX_PRELUDE = void 0; +const G = '(typeof globalThis !== "undefined" ? globalThis : this)'; +// ── ENCODING ── base64, UTF-8, and the internal byte helpers (__rqb) every other +// block shares. Must be first: BINARY/CRYPTO/NETWORK depend on __rqb. +const ENCODING = String.raw ` +(function (g) { + "use strict"; + + // ---- base64 (atob / btoa) ---- + var __B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + g.btoa = function (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; + }; + g.atob = function (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; + }; + + // ---- TextEncoder / TextDecoder (UTF-8) ---- + function TextEncoder() {} + TextEncoder.prototype.encode = function (str) { + str = String(str === undefined ? "" : str); + var out = []; + for (var i = 0; i < str.length; i++) { + var c = str.charCodeAt(i); + if (c < 0x80) out.push(c); + else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); + else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) { + var c2 = str.charCodeAt(i + 1); + if (c2 >= 0xdc00 && c2 <= 0xdfff) { + var cp = 0x10000 + ((c - 0xd800) << 10) + (c2 - 0xdc00); + out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + i++; + } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } + return new Uint8Array(out); + }; + function TextDecoder() {} + TextDecoder.prototype.decode = function (buf) { + if (!buf) return ""; + var bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf.buffer || buf); + var out = "", i = 0; + while (i < bytes.length) { + var b = bytes[i++]; + if (b < 0x80) out += String.fromCharCode(b); + else if (b >= 0xc0 && b < 0xe0) out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f)); + else if (b >= 0xe0 && b < 0xf0) out += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f)); + else { + var cp2 = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f); + cp2 -= 0x10000; + out += String.fromCharCode(0xd800 + (cp2 >> 10), 0xdc00 + (cp2 & 0x3ff)); + } + } + return out; + }; + g.TextEncoder = TextEncoder; + g.TextDecoder = TextDecoder; + + // ---- internal byte helpers shared by BINARY / CRYPTO / NETWORK ---- + var _hex = "0123456789abcdef"; + g.__rqb = { + u8: function (s) { return Array.prototype.slice.call(new TextEncoder().encode(String(s))); }, + s8: function (b) { return new TextDecoder().decode(new Uint8Array(b)); }, + toHex: function (b) { var o = ""; for (var i = 0; i < b.length; i++) { o += _hex[(b[i] >> 4) & 15] + _hex[b[i] & 15]; } return o; }, + fromHex: function (s) { s = String(s); var o = []; for (var i = 0; i + 1 < s.length; i += 2) { o.push(parseInt(s.substr(i, 2), 16)); } return o; }, + toB64: function (b) { var s = ""; for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i] & 255); return g.btoa(s); }, + fromB64: function (s) { var bin = g.atob(String(s)); var o = []; for (var i = 0; i < bin.length; i++) o.push(bin.charCodeAt(i) & 255); return o; } + }; +})(${G}); +`; +// ── BINARY ── Buffer + Blob (pure JS over Uint8Array; utf8/base64/hex). +const BINARY = String.raw ` +(function (g) { + "use strict"; + var B = g.__rqb, _u8 = B.u8, _s8 = B.s8, _toHex = B.toHex, _fromHex = B.fromHex, _toB64 = B.toB64, _fromB64 = B.fromB64; + + // ---- Buffer ---- + function _mkBuf(bytes) { + var u = new Uint8Array(bytes); u.__isBuffer = true; + u.toString = function (enc) { + enc = (enc || "utf8").toLowerCase(); var a = Array.prototype.slice.call(this); + if (enc === "base64") return _toB64(a); + if (enc === "hex") return _toHex(a); + if (enc === "latin1" || enc === "binary") { var s = ""; for (var i = 0; i < a.length; i++) s += String.fromCharCode(a[i]); return s; } + return _s8(a); + }; + return u; + } + function Buffer() {} + Buffer.from = function (value, enc) { + var bytes; + if (typeof value === "string") { + enc = (enc || "utf8").toLowerCase(); + if (enc === "base64") bytes = _fromB64(value); + else if (enc === "hex") bytes = _fromHex(value); + else if (enc === "latin1" || enc === "binary") { bytes = []; for (var i = 0; i < value.length; i++) bytes.push(value.charCodeAt(i) & 255); } + else bytes = _u8(value); + } + else if (value instanceof Uint8Array) { bytes = Array.prototype.slice.call(value); } + else if (value instanceof ArrayBuffer) { bytes = Array.prototype.slice.call(new Uint8Array(value)); } + else if (value && value.buffer instanceof ArrayBuffer) { bytes = Array.prototype.slice.call(new Uint8Array(value.buffer, value.byteOffset || 0, value.byteLength)); } + else if (Array.isArray(value)) { bytes = value.slice(); } + else bytes = []; + return _mkBuf(bytes); + }; + Buffer.alloc = function (n, fill) { var b = []; for (var i = 0; i < n; i++) b.push(typeof fill === "number" ? (fill & 255) : 0); return _mkBuf(b); }; + Buffer.isBuffer = function (x) { return !!(x && x.__isBuffer); }; + Buffer.byteLength = function (s, enc) { return Buffer.from(s, enc).length; }; + Buffer.concat = function (list) { var all = []; for (var i = 0; i < list.length; i++) { for (var j = 0; j < list[i].length; j++) all.push(list[i][j]); } return _mkBuf(all); }; + g.Buffer = Buffer; + + // ---- Blob ---- + function Blob(parts, opts) { + var bytes = []; parts = parts || []; + for (var i = 0; i < parts.length; i++) { + var p = parts[i]; + if (typeof p === "string") { var b = _u8(p); for (var j = 0; j < b.length; j++) bytes.push(b[j]); } + else if (p && p.__isBlob) { for (var n = 0; n < p.__bytes.length; n++) bytes.push(p.__bytes[n]); } + else if (p instanceof Uint8Array || (p && p.__isBuffer)) { for (var k = 0; k < p.length; k++) bytes.push(p[k]); } + else if (p instanceof ArrayBuffer) { var u = new Uint8Array(p); for (var m = 0; m < u.length; m++) bytes.push(u[m]); } + else { var s = _u8(String(p)); for (var q = 0; q < s.length; q++) bytes.push(s[q]); } + } + this.__isBlob = true; this.__bytes = bytes; this.size = bytes.length; this.type = (opts && opts.type) || ""; + } + Blob.prototype.text = function () { return Promise.resolve(_s8(this.__bytes)); }; + Blob.prototype.arrayBuffer = function () { return Promise.resolve(new Uint8Array(this.__bytes).buffer); }; + Blob.prototype.slice = function (s, e, type) { var b = this.__bytes.slice(s, e); var nb = new Blob([], {}); nb.__bytes = b; nb.size = b.length; nb.type = type || ""; return nb; }; + g.Blob = Blob; +})(${G}); +`; +// ── URL ── URL + URLSearchParams (pure JS; QuickJS has no URL constructor). +const URL_API = String.raw ` (function (g) { "use strict"; @@ -134,46 +261,57 @@ exports.SANDBOX_POLYFILLS = String.raw ` URL.prototype.toString = function () { return this.href; }; URL.prototype.toJSON = function () { return this.href; }; - // ---- TextEncoder / TextDecoder (UTF-8) ---- - function TextEncoder() {} - TextEncoder.prototype.encode = function (str) { - str = String(str === undefined ? "" : str); - var out = []; - for (var i = 0; i < str.length; i++) { - var c = str.charCodeAt(i); - if (c < 0x80) out.push(c); - else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); - else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) { - var c2 = str.charCodeAt(i + 1); - if (c2 >= 0xdc00 && c2 <= 0xdfff) { - var cp = 0x10000 + ((c - 0xd800) << 10) + (c2 - 0xdc00); - out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); - i++; - } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); - } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); - } - return new Uint8Array(out); - }; - function TextDecoder() {} - TextDecoder.prototype.decode = function (buf) { - if (!buf) return ""; - var bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf.buffer || buf); - var out = "", i = 0; - while (i < bytes.length) { - var b = bytes[i++]; - if (b < 0x80) out += String.fromCharCode(b); - else if (b >= 0xc0 && b < 0xe0) out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f)); - else if (b >= 0xe0 && b < 0xf0) out += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f)); - else { - var cp2 = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f); - cp2 -= 0x10000; - out += String.fromCharCode(0xd800 + (cp2 >> 10), 0xdc00 + (cp2 & 0x3ff)); - } - } - return out; - }; + g.URLSearchParams = URLSearchParams; + g.URL = URL; +})(${G}); +`; +// ── HTTP_TYPES ── Headers, FormData, Request, Response (data holders used by NETWORK). +const HTTP_TYPES = String.raw ` +(function (g) { + "use strict"; - // ---- structuredClone ---- + // ---- Headers ---- + function Headers(obj) { + this.__h = {}; + for (var k in (obj || {})) if (Object.prototype.hasOwnProperty.call(obj, k)) this.__h[String(k).toLowerCase()] = obj[k]; + } + Headers.prototype.get = function (k) { var v = this.__h[String(k).toLowerCase()]; return v == null ? null : v; }; + Headers.prototype.has = function (k) { return String(k).toLowerCase() in this.__h; }; + Headers.prototype.set = function (k, v) { this.__h[String(k).toLowerCase()] = v; }; + Headers.prototype.append = function (k, v) { this.__h[String(k).toLowerCase()] = v; }; + Headers.prototype["delete"] = function (k) { delete this.__h[String(k).toLowerCase()]; }; + Headers.prototype.forEach = function (cb, t) { for (var k in this.__h) cb.call(t, this.__h[k], k, this); }; + g.Headers = Headers; + + // ---- FormData ---- + function FormData() { this.__e = []; } + FormData.prototype.append = function (k, v, fn) { this.__e.push([String(k), v, fn]); }; + FormData.prototype.set = function (k, v, fn) { this["delete"](k); this.__e.push([String(k), v, fn]); }; + FormData.prototype.get = function (k) { k = String(k); for (var i = 0; i < this.__e.length; i++) if (this.__e[i][0] === k) return this.__e[i][1]; return null; }; + FormData.prototype.getAll = function (k) { k = String(k); var r = []; for (var i = 0; i < this.__e.length; i++) if (this.__e[i][0] === k) r.push(this.__e[i][1]); return r; }; + FormData.prototype.has = function (k) { return this.get(String(k)) !== null; }; + FormData.prototype["delete"] = function (k) { k = String(k); this.__e = this.__e.filter(function (e) { return e[0] !== k; }); }; + FormData.prototype.forEach = function (cb, t) { for (var i = 0; i < this.__e.length; i++) cb.call(t, this.__e[i][1], this.__e[i][0], this); }; + FormData.prototype.entries = function () { return this.__e.map(function (e) { return [e[0], e[1]]; }); }; + FormData.prototype.keys = function () { return this.__e.map(function (e) { return e[0]; }); }; + FormData.prototype.values = function () { return this.__e.map(function (e) { return e[1]; }); }; + g.FormData = FormData; + + // ---- Request / Response (data holders) ---- + function Request(input, init) { init = init || {}; this.url = (input && input.url) ? input.url : String(input); this.method = init.method || (input && input.method) || "GET"; this.headers = new g.Headers(init.headers || (input && input.headers) || {}); this.body = init.body != null ? init.body : (input && input.body); this.__isRequest = true; } + Request.prototype.clone = function () { return new Request(this, {}); }; + g.Request = Request; + function Response(body, init) { init = init || {}; this.__body = body == null ? "" : String(body); this.status = init.status != null ? init.status : 200; this.statusText = init.statusText || ""; this.ok = this.status >= 200 && this.status < 300; this.headers = new g.Headers(init.headers || {}); this.__isResponse = true; } + Response.prototype.text = function () { return Promise.resolve(this.__body); }; + Response.prototype.json = function () { var b = this.__body; return Promise.resolve(JSON.parse(b)); }; + Response.prototype.arrayBuffer = function () { return Promise.resolve(new Uint8Array(g.__rqb.u8(this.__body)).buffer); }; + g.Response = Response; +})(${G}); +`; +// ── CLONE ── structuredClone (deep clone of JSON-ish + Date/RegExp/Map/Set, cyclic-safe). +const CLONE = String.raw ` +(function (g) { + "use strict"; function structuredClone(value) { function cl(x, seen) { if (x === null || typeof x !== "object") return x; @@ -190,24 +328,16 @@ exports.SANDBOX_POLYFILLS = String.raw ` } return cl(value, new Map()); } - - g.URLSearchParams = URLSearchParams; - g.URL = URL; - g.TextEncoder = TextEncoder; - g.TextDecoder = TextDecoder; g.structuredClone = structuredClone; -})(typeof globalThis !== "undefined" ? globalThis : this); +})(${G}); `; -// In-guest shims for the HOST-BRIDGED capabilities (crypto, fetch). These call -// the host functions __hostCrypto (sync) / __hostFetch (async) that -// executeUserFunction installs. Only JSON strings cross the boundary — the host -// handlers receive a string and return a string (fetch via a guest promise the -// host resolves), so no host object is ever exposed to the rule code. -exports.SANDBOX_BRIDGE_SHIMS = String.raw ` +// ── CRYPTO ── real entropy/digests via the host's node:crypto (bridge: __hostCrypto). +// require('crypto') maps here; any other require(...) throws a guided error. +const CRYPTO = String.raw ` (function (g) { "use strict"; + var B = g.__rqb, _u8 = B.u8, _toB64 = B.toB64, _fromHex = B.fromHex; - // ---- crypto (real entropy/digests via the host's node:crypto) ---- g.crypto = { randomUUID: function () { return JSON.parse(__hostCrypto(JSON.stringify({ op: "randomUUID" }))).uuid; @@ -218,233 +348,146 @@ exports.SANDBOX_BRIDGE_SHIMS = String.raw ` return arr; } }; - // node:crypto subset reachable via require('crypto') + + // ---- crypto.subtle.digest (webcrypto) — keyless hashing; binary-exact via base64 ---- + g.crypto.subtle = { + digest: function (algo, data) { + var name = (typeof algo === "string" ? algo : (algo && algo.name) || "SHA-256").toLowerCase().replace("-", ""); + var bytes; + if (typeof data === "string") bytes = _u8(data); + else if (data instanceof ArrayBuffer) bytes = Array.prototype.slice.call(new Uint8Array(data)); + else if (data && data.buffer) bytes = Array.prototype.slice.call(new Uint8Array(data.buffer, data.byteOffset || 0, data.byteLength)); + else bytes = Array.prototype.slice.call(data || []); + var hex = JSON.parse(__hostCrypto(JSON.stringify({ op: "hash", algo: name, data: _toB64(bytes), dataEncoding: "base64", encoding: "hex" }))).digest; + return Promise.resolve(new Uint8Array(_fromHex(hex)).buffer); + } + }; + + // ---- node:crypto subset (reachable via require('crypto')) ---- var nodeCrypto = { randomUUID: g.crypto.randomUUID, randomBytes: function (n) { - var r = JSON.parse(__hostCrypto(JSON.stringify({ op: "randomBytes", size: n }))); - return r.bytes; + return JSON.parse(__hostCrypto(JSON.stringify({ op: "randomBytes", size: n }))).bytes; }, createHash: function (algo) { var buf = ""; return { update: function (d) { buf += String(d); return this; }, - digest: function (enc) { - return JSON.parse(__hostCrypto(JSON.stringify({ op: "hash", algo: algo, data: buf, encoding: enc || "hex" }))).digest; - } + digest: function (enc) { return JSON.parse(__hostCrypto(JSON.stringify({ op: "hash", algo: algo, data: buf, encoding: enc || "hex" }))).digest; } + }; + }, + createHmac: function (algo, key) { + var buf = ""; + return { + update: function (d) { buf += String(d); return this; }, + digest: function (enc) { return JSON.parse(__hostCrypto(JSON.stringify({ op: "hmac", algo: algo, key: String(key), data: buf, encoding: enc || "hex" }))).digest; } }; } }; - // ---- require() — safe modules map to bridges; everything else is a guided error ---- g.require = function (name) { if (name === "crypto" || name === "node:crypto") return nodeCrypto; throw new Error("Cannot require('" + name + "') — modules are not available in sandboxed rules"); }; +})(${G}); +`; +// ── NETWORK ── fetch (single, body-aware) + XMLHttpRequest + WebSocket-guard +// (bridge: __hostFetch; http(s)-only + credentials:'omit' policy is enforced host-side). +const NETWORK = String.raw ` +(function (g) { + "use strict"; + var _s8 = g.__rqb.s8; - // ---- fetch (real HTTP via the host; copied request/response only) ---- - function Headers(obj) { - this.__h = {}; - for (var k in (obj || {})) if (Object.prototype.hasOwnProperty.call(obj, k)) this.__h[String(k).toLowerCase()] = obj[k]; + function _multipart(fd) { + var boundary = "----RQFormBoundary" + crypto.randomUUID().replace(/-/g, ""); + var CRLF = "\r\n", body = ""; + fd.__e.forEach(function (e) { + var name = e[0], val = e[1], fn = e[2]; + body += "--" + boundary + CRLF; + if (val && val.__isBlob) { body += 'Content-Disposition: form-data; name="' + name + '"' + (fn ? '; filename="' + fn + '"' : "") + CRLF; if (val.type) body += "Content-Type: " + val.type + CRLF; body += CRLF + _s8(val.__bytes) + CRLF; } + else { body += 'Content-Disposition: form-data; name="' + name + '"' + CRLF + CRLF + String(val) + CRLF; } + }); + body += "--" + boundary + "--" + CRLF; + return { body: body, contentType: "multipart/form-data; boundary=" + boundary }; } - Headers.prototype.get = function (k) { var v = this.__h[String(k).toLowerCase()]; return v == null ? null : v; }; - Headers.prototype.has = function (k) { return String(k).toLowerCase() in this.__h; }; - Headers.prototype.forEach = function (cb, t) { for (var k in this.__h) cb.call(t, this.__h[k], k, this); }; - g.Headers = Headers; - g.fetch = function (url, init) { + // One fetch: accepts a Request or url, normalises FormData/Blob/URLSearchParams + // bodies + Headers, marshals to the host bridge, returns a Response-like object. + g.fetch = function (input, init) { init = init || {}; - var hdrs = init.headers || {}; - if (hdrs && typeof hdrs.forEach === "function" && hdrs.__h) { var o = {}; hdrs.forEach(function (v, k) { o[k] = v; }); hdrs = o; } - var req = JSON.stringify({ - url: String(url), - method: init.method || "GET", - headers: hdrs, - body: init.body != null ? String(init.body) : undefined - }); + if (input instanceof g.Request) { init = { method: input.method, headers: input.headers, body: input.body }; input = input.url; } + var body = init.body, headers = init.headers || {}; + if (body && body.__isBlob) { body = _s8(body.__bytes); } + else if (body instanceof g.FormData) { var mp = _multipart(body); body = mp.body; var o = {}; if (headers && headers.forEach) { headers.forEach(function (v, k) { o[k] = v; }); } else { for (var k in headers) o[k] = headers[k]; } o["content-type"] = mp.contentType; headers = o; } + else if (body instanceof g.URLSearchParams) { body = body.toString(); var o2 = {}; for (var k2 in headers) o2[k2] = headers[k2]; if (!o2["content-type"] && !o2["Content-Type"]) o2["content-type"] = "application/x-www-form-urlencoded"; headers = o2; } + if (headers && typeof headers.forEach === "function" && headers.__h) { var oh = {}; headers.forEach(function (v, k) { oh[k] = v; }); headers = oh; } + var req = JSON.stringify({ url: String(input), method: init.method || "GET", headers: headers, body: body != null ? String(body) : undefined }); return __hostFetch(req).then(function (jsonStr) { var d = JSON.parse(jsonStr); if (d && d.__fetchError) throw new Error(d.__fetchError); return { status: d.status, statusText: d.statusText, ok: d.ok, url: d.url, - headers: new Headers(d.headers), + headers: new g.Headers(d.headers), text: function () { return Promise.resolve(d.body); }, json: function () { return Promise.resolve(JSON.parse(d.body)); } }; }); }; -})(typeof globalThis !== "undefined" ? globalThis : this); -`; -// Additional Web/Node APIs layered on top of the polyfills + bridges. Pure-JS -// where possible (Buffer, timers, Blob, FormData, Request/Response, performance); -// XHR rides the fetch bridge; createHmac/subtle.digest ride the crypto bridge. -// MUST be concatenated AFTER SANDBOX_POLYFILLS + SANDBOX_BRIDGE_SHIMS (uses -// TextEncoder/Headers/URLSearchParams, fetch, __hostCrypto, atob/btoa). -exports.SANDBOX_EXTRA_SHIMS = String.raw ` -(function (g) { - "use strict"; - var _hex = "0123456789abcdef"; - function _u8(s){ return Array.prototype.slice.call(new TextEncoder().encode(String(s))); } - function _s8(b){ return new TextDecoder().decode(new Uint8Array(b)); } - function _toHex(b){ var o=""; for(var i=0;i>4)&15]+_hex[b[i]&15]; } return o; } - function _fromHex(s){ s=String(s); var o=[]; for(var i=0;i+1=200&&this.status<300; this.headers=new g.Headers(init.headers||{}); this.__isResponse=true; } - Response.prototype.text=function(){ return Promise.resolve(this.__body); }; - Response.prototype.json=function(){ var b=this.__body; return Promise.resolve(JSON.parse(b)); }; - Response.prototype.arrayBuffer=function(){ return Promise.resolve(new Uint8Array(_u8(this.__body)).buffer); }; - g.Response = Response; - - // ---- fetch augmentation: Request input + FormData/Blob/URLSearchParams bodies ---- - var __origFetch = g.fetch; - function _multipart(fd){ - var boundary = "----RQFormBoundary" + crypto.randomUUID().replace(/-/g,""); - var CRLF = "\r\n", body = ""; - fd.__e.forEach(function(e){ var name=e[0], val=e[1], fn=e[2]; - body += "--"+boundary+CRLF; - if(val && val.__isBlob){ body += 'Content-Disposition: form-data; name="'+name+'"'+(fn?'; filename="'+fn+'"':'')+CRLF; if(val.type) body+="Content-Type: "+val.type+CRLF; body+=CRLF+_s8(val.__bytes)+CRLF; } - else { body += 'Content-Disposition: form-data; name="'+name+'"'+CRLF+CRLF+String(val)+CRLF; } - }); - body += "--"+boundary+"--"+CRLF; - return { body: body, contentType: "multipart/form-data; boundary="+boundary }; - } - g.fetch = function(input, init){ - init = init || {}; - if (input instanceof Request){ init={ method: input.method, headers: input.headers, body: input.body }; input = input.url; } - var body = init.body, headers = init.headers || {}; - if (body && body.__isBlob){ body = _s8(body.__bytes); } - else if (body instanceof FormData){ var mp=_multipart(body); body=mp.body; var o={}; if(headers&&headers.forEach){ headers.forEach(function(v,k){o[k]=v;}); } else { for(var k in headers) o[k]=headers[k]; } o["content-type"]=mp.contentType; headers=o; } - else if (body instanceof g.URLSearchParams){ body=body.toString(); var o2={}; for(var k2 in headers) o2[k2]=headers[k2]; if(!o2["content-type"]&&!o2["Content-Type"]) o2["content-type"]="application/x-www-form-urlencoded"; headers=o2; } - var init2={}; for(var kk in init) init2[kk]=init[kk]; init2.body=body; init2.headers=headers; - return __origFetch(input, init2); - }; // ---- XMLHttpRequest (async only; sync throws) over the fetch bridge ---- - function XMLHttpRequest(){ this.readyState=0; this.status=0; this.statusText=""; this.responseText=""; this.response=""; this.responseType=""; this._h={}; this._m="GET"; this._u=""; this._rh={}; this.onreadystatechange=null; this.onload=null; this.onerror=null; this.onloadend=null; } - XMLHttpRequest.UNSENT=0; XMLHttpRequest.OPENED=1; XMLHttpRequest.HEADERS_RECEIVED=2; XMLHttpRequest.LOADING=3; XMLHttpRequest.DONE=4; - XMLHttpRequest.prototype.open=function(method,url,async){ if(async===false) throw new Error("Synchronous XMLHttpRequest is not supported in sandboxed rules; use async or fetch()."); this._m=method||"GET"; this._u=String(url); this.readyState=1; if(this.onreadystatechange) this.onreadystatechange(); }; - XMLHttpRequest.prototype.setRequestHeader=function(k,v){ this._h[k]=v; }; - XMLHttpRequest.prototype.getAllResponseHeaders=function(){ var s=""; for(var k in this._rh) s+=k+": "+this._rh[k]+"\r\n"; return s; }; - XMLHttpRequest.prototype.getResponseHeader=function(k){ k=String(k).toLowerCase(); return (k in this._rh)?this._rh[k]:null; }; - XMLHttpRequest.prototype.abort=function(){}; - XMLHttpRequest.prototype.send=function(body){ var self=this; - g.fetch(this._u,{ method:this._m, headers:this._h, body:body }).then(function(res){ self.status=res.status; self.statusText=res.statusText||""; self._rh={}; if(res.headers&&res.headers.forEach) res.headers.forEach(function(v,k){ self._rh[String(k).toLowerCase()]=v; }); return res.text(); }) - .then(function(text){ self.responseText=text; self.response=(self.responseType==="json")?(function(){try{return JSON.parse(text);}catch(e){return null;}})():text; self.readyState=4; if(self.onreadystatechange) self.onreadystatechange(); if(self.onload) self.onload(); if(self.onloadend) self.onloadend(); }) - .catch(function(e){ self.readyState=4; if(self.onreadystatechange) self.onreadystatechange(); if(self.onerror) self.onerror(e); if(self.onloadend) self.onloadend(); }); + function XMLHttpRequest() { this.readyState = 0; this.status = 0; this.statusText = ""; this.responseText = ""; this.response = ""; this.responseType = ""; this._h = {}; this._m = "GET"; this._u = ""; this._rh = {}; this.onreadystatechange = null; this.onload = null; this.onerror = null; this.onloadend = null; } + XMLHttpRequest.UNSENT = 0; XMLHttpRequest.OPENED = 1; XMLHttpRequest.HEADERS_RECEIVED = 2; XMLHttpRequest.LOADING = 3; XMLHttpRequest.DONE = 4; + XMLHttpRequest.prototype.open = function (method, url, async) { if (async === false) throw new Error("Synchronous XMLHttpRequest is not supported in sandboxed rules; use async or fetch()."); this._m = method || "GET"; this._u = String(url); this.readyState = 1; if (this.onreadystatechange) this.onreadystatechange(); }; + XMLHttpRequest.prototype.setRequestHeader = function (k, v) { this._h[k] = v; }; + XMLHttpRequest.prototype.getAllResponseHeaders = function () { var s = ""; for (var k in this._rh) s += k + ": " + this._rh[k] + "\r\n"; return s; }; + XMLHttpRequest.prototype.getResponseHeader = function (k) { k = String(k).toLowerCase(); return (k in this._rh) ? this._rh[k] : null; }; + XMLHttpRequest.prototype.abort = function () {}; + XMLHttpRequest.prototype.send = function (body) { + var self = this; + g.fetch(this._u, { method: this._m, headers: this._h, body: body }).then(function (res) { self.status = res.status; self.statusText = res.statusText || ""; self._rh = {}; if (res.headers && res.headers.forEach) res.headers.forEach(function (v, k) { self._rh[String(k).toLowerCase()] = v; }); return res.text(); }) + .then(function (text) { self.responseText = text; self.response = (self.responseType === "json") ? (function () { try { return JSON.parse(text); } catch (e) { return null; } })() : text; self.readyState = 4; if (self.onreadystatechange) self.onreadystatechange(); if (self.onload) self.onload(); if (self.onloadend) self.onloadend(); }) + .catch(function (e) { self.readyState = 4; if (self.onreadystatechange) self.onreadystatechange(); if (self.onerror) self.onerror(e); if (self.onloadend) self.onloadend(); }); }; g.XMLHttpRequest = XMLHttpRequest; // ---- WebSocket: unsupported (a persistent connection can't outlive a per-request execution) ---- - g.WebSocket = function(){ throw new Error("WebSocket is not available in sandboxed rules (no persistent connections)."); }; - - // ---- crypto: createHmac (node) + subtle.digest (webcrypto) ---- - var __nc = (function(){ try { return g.require("crypto"); } catch(e){ return null; } })(); - if (__nc){ - __nc.createHmac = function(algo, key){ var buf=""; - return { update:function(d){ buf+=String(d); return this; }, digest:function(enc){ return JSON.parse(__hostCrypto(JSON.stringify({op:"hmac",algo:algo,key:String(key),data:buf,encoding:enc||"hex"}))).digest; } }; - }; - } - if (g.crypto && !g.crypto.subtle){ - g.crypto.subtle = { - digest: function(algo, data){ - var name=(typeof algo==="string"?algo:(algo&&algo.name)||"SHA-256").toLowerCase().replace("-",""); - var bytes; - if(typeof data==="string") bytes=_u8(data); - else if(data instanceof ArrayBuffer) bytes=Array.prototype.slice.call(new Uint8Array(data)); - else if(data && data.buffer) bytes=Array.prototype.slice.call(new Uint8Array(data.buffer, data.byteOffset||0, data.byteLength)); - else bytes=Array.prototype.slice.call(data||[]); - var hex=JSON.parse(__hostCrypto(JSON.stringify({op:"hash",algo:name,data:_toB64(bytes),dataEncoding:"base64",encoding:"hex"}))).digest; - return Promise.resolve(new Uint8Array(_fromHex(hex)).buffer); - } - }; - } -})(typeof globalThis !== "undefined" ? globalThis : this); + g.WebSocket = function () { throw new Error("WebSocket is not available in sandboxed rules (no persistent connections)."); }; +})(${G}); +`; +// ── TIMERS ── setTimeout honors the real delay via __hostTimer (clamped host-side +// to the execution budget); setInterval is a no-op (a repeating timer can't outlive +// a per-request execution); queueMicrotask + performance are pure. +const TIMERS = String.raw ` +(function (g) { + "use strict"; + var _cancelled = {}, _tid = 0; + g.setTimeout = function (fn, ms) { + var id = ++_tid; var args = Array.prototype.slice.call(arguments, 2); + __hostTimer(Number(ms) || 0).then(function () { if (!_cancelled[id] && typeof fn === "function") fn.apply(null, args); }); + return id; + }; + g.clearTimeout = function (id) { _cancelled[id] = true; }; + g.setInterval = function () { return ++_tid; }; + g.clearInterval = function () {}; + g.queueMicrotask = function (fn) { Promise.resolve().then(fn); }; + g.performance = g.performance || { now: function () { return Date.now(); }, timeOrigin: 0 }; +})(${G}); `; +// ── HARNESS ── the run environment. MUST be top-level (not an IIFE) so `console`, +// `args`, `$sharedState`, `__OUTPUT` are script-globals the user-fn wrapper reads. +// Reads the host-injected `__argsJson`/`__sharedStateJson`. ';'-separated, no +// '//' comments, so it concatenates safely. +const HARNESS = [ + "var __logs = [];", + "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(""); +/** + * The complete in-guest prelude, concatenated in dependency order. index.ts + * appends the user-function wrapper after this. + */ +exports.SANDBOX_PRELUDE = ENCODING + BINARY + URL_API + HTTP_TYPES + CLONE + CRYPTO + NETWORK + TIMERS + HARNESS; diff --git a/src/utils/index.ts b/src/utils/index.ts index 762d894..a719ccc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -11,12 +11,7 @@ import { } from "quickjs-emscripten-core"; import { randomUUID, randomBytes, createHash, createHmac } from "crypto"; import GlobalStateProvider from "../components/proxy-middleware/middlewares/state"; -import { - SANDBOX_SETUP, - SANDBOX_POLYFILLS, - SANDBOX_BRIDGE_SHIMS, - SANDBOX_EXTRA_SHIMS, -} from "./sandbox-globals"; +import { SANDBOX_PRELUDE } from "./sandbox-globals"; /** * RQ-2426: rule-supplied "code" rules (Modify Request/Response) used to be run @@ -310,10 +305,7 @@ export async function executeUserFunction( // 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_POLYFILLS + - SANDBOX_BRIDGE_SHIMS + - SANDBOX_EXTRA_SHIMS + - SANDBOX_SETUP + + SANDBOX_PRELUDE + "Promise.resolve((" + functionString + "\n)(args)).then(function (r) {" + diff --git a/src/utils/sandbox-globals.ts b/src/utils/sandbox-globals.ts index 5349c0a..ee2b7e7 100644 --- a/src/utils/sandbox-globals.ts +++ b/src/utils/sandbox-globals.ts @@ -1,51 +1,180 @@ /** - * sandbox-globals — the JavaScript SOURCE that runs INSIDE the QuickJS guest realm - * (split out of utils/index.ts for readability/debuggability). These are plain - * strings injected into the sandbox; nothing here executes in the host. index.ts - * owns the host side (module/context lifecycle, the crypto/fetch bridges, the - * pump loop). Three blocks, concatenated in this order by executeUserFunction: - * SANDBOX_POLYFILLS — pure-JS web/Node global shims (URL, encoding, clone…) - * SANDBOX_BRIDGE_SHIMS — guest halves of the host bridges (crypto, fetch, require) - * SANDBOX_SETUP — console/atob/btoa + args/$sharedState/__OUTPUT wiring + * sandbox-globals — the JavaScript SOURCE that runs INSIDE the QuickJS guest realm. + * + * These are plain strings injected into the sandbox; nothing here executes in the + * host. `index.ts` owns the host side (module/context lifecycle, the + * crypto/fetch/timer bridges, the pump loop). + * + * Organised by concern, each an IIFE that augments `globalThis` (except HARNESS, + * which must stay top-level so its `var`/`function` bindings are script-global). + * They are concatenated in DEPENDENCY ORDER into `SANDBOX_PRELUDE`: + * + * ENCODING atob/btoa, TextEncoder/Decoder, shared byte helpers (__rqb) + * BINARY Buffer, Blob (use __rqb) + * URL URL, URLSearchParams + * HTTP_TYPES Headers, FormData, Request, Response + * CLONE structuredClone + * CRYPTO crypto.* + require() [host bridge: __hostCrypto] + * NETWORK fetch, XMLHttpRequest, WebSocket [host bridge: __hostFetch] + * TIMERS setTimeout/setInterval/…, performance [host bridge: __hostTimer] + * HARNESS console + args/$sharedState/__OUTPUT (top-level; reads host-injected + * __argsJson/__sharedStateJson; the user fn wrapper runs after it) + * + * Security: pure shims never touch the host. The bridged blocks (CRYPTO/NETWORK/ + * TIMERS) call host functions that take and return only JSON-serialisable data — + * no host object is ever handed to the guest, so there is no escape surface. + * `String.raw` keeps regex/`\r\n` backslashes literal so they reach the sandbox JS. */ -// 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. -export 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(""); +const G = '(typeof globalThis !== "undefined" ? globalThis : this)'; -// Pure-JS polyfills for common Web/Node globals that QuickJS (a bare ECMAScript -// engine) does not provide. All implemented INSIDE the sandbox using only QuickJS -// built-ins — no host object crosses the boundary, so they add no escape surface -// (same safety model as atob/btoa). `String.raw` keeps regex backslashes literal. -// NOTE: `crypto` and `fetch` are NOT here — they need real host capabilities and -// live in SANDBOX_BRIDGE_SHIMS (the guest halves of the host bridges). -export const SANDBOX_POLYFILLS = String.raw` +// ── ENCODING ── base64, UTF-8, and the internal byte helpers (__rqb) every other +// block shares. Must be first: BINARY/CRYPTO/NETWORK depend on __rqb. +const ENCODING = String.raw` +(function (g) { + "use strict"; + + // ---- base64 (atob / btoa) ---- + var __B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + g.btoa = function (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; + }; + g.atob = function (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; + }; + + // ---- TextEncoder / TextDecoder (UTF-8) ---- + function TextEncoder() {} + TextEncoder.prototype.encode = function (str) { + str = String(str === undefined ? "" : str); + var out = []; + for (var i = 0; i < str.length; i++) { + var c = str.charCodeAt(i); + if (c < 0x80) out.push(c); + else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); + else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) { + var c2 = str.charCodeAt(i + 1); + if (c2 >= 0xdc00 && c2 <= 0xdfff) { + var cp = 0x10000 + ((c - 0xd800) << 10) + (c2 - 0xdc00); + out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + i++; + } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } + return new Uint8Array(out); + }; + function TextDecoder() {} + TextDecoder.prototype.decode = function (buf) { + if (!buf) return ""; + var bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf.buffer || buf); + var out = "", i = 0; + while (i < bytes.length) { + var b = bytes[i++]; + if (b < 0x80) out += String.fromCharCode(b); + else if (b >= 0xc0 && b < 0xe0) out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f)); + else if (b >= 0xe0 && b < 0xf0) out += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f)); + else { + var cp2 = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f); + cp2 -= 0x10000; + out += String.fromCharCode(0xd800 + (cp2 >> 10), 0xdc00 + (cp2 & 0x3ff)); + } + } + return out; + }; + g.TextEncoder = TextEncoder; + g.TextDecoder = TextDecoder; + + // ---- internal byte helpers shared by BINARY / CRYPTO / NETWORK ---- + var _hex = "0123456789abcdef"; + g.__rqb = { + u8: function (s) { return Array.prototype.slice.call(new TextEncoder().encode(String(s))); }, + s8: function (b) { return new TextDecoder().decode(new Uint8Array(b)); }, + toHex: function (b) { var o = ""; for (var i = 0; i < b.length; i++) { o += _hex[(b[i] >> 4) & 15] + _hex[b[i] & 15]; } return o; }, + fromHex: function (s) { s = String(s); var o = []; for (var i = 0; i + 1 < s.length; i += 2) { o.push(parseInt(s.substr(i, 2), 16)); } return o; }, + toB64: function (b) { var s = ""; for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i] & 255); return g.btoa(s); }, + fromB64: function (s) { var bin = g.atob(String(s)); var o = []; for (var i = 0; i < bin.length; i++) o.push(bin.charCodeAt(i) & 255); return o; } + }; +})(${G}); +`; + +// ── BINARY ── Buffer + Blob (pure JS over Uint8Array; utf8/base64/hex). +const BINARY = String.raw` +(function (g) { + "use strict"; + var B = g.__rqb, _u8 = B.u8, _s8 = B.s8, _toHex = B.toHex, _fromHex = B.fromHex, _toB64 = B.toB64, _fromB64 = B.fromB64; + + // ---- Buffer ---- + function _mkBuf(bytes) { + var u = new Uint8Array(bytes); u.__isBuffer = true; + u.toString = function (enc) { + enc = (enc || "utf8").toLowerCase(); var a = Array.prototype.slice.call(this); + if (enc === "base64") return _toB64(a); + if (enc === "hex") return _toHex(a); + if (enc === "latin1" || enc === "binary") { var s = ""; for (var i = 0; i < a.length; i++) s += String.fromCharCode(a[i]); return s; } + return _s8(a); + }; + return u; + } + function Buffer() {} + Buffer.from = function (value, enc) { + var bytes; + if (typeof value === "string") { + enc = (enc || "utf8").toLowerCase(); + if (enc === "base64") bytes = _fromB64(value); + else if (enc === "hex") bytes = _fromHex(value); + else if (enc === "latin1" || enc === "binary") { bytes = []; for (var i = 0; i < value.length; i++) bytes.push(value.charCodeAt(i) & 255); } + else bytes = _u8(value); + } + else if (value instanceof Uint8Array) { bytes = Array.prototype.slice.call(value); } + else if (value instanceof ArrayBuffer) { bytes = Array.prototype.slice.call(new Uint8Array(value)); } + else if (value && value.buffer instanceof ArrayBuffer) { bytes = Array.prototype.slice.call(new Uint8Array(value.buffer, value.byteOffset || 0, value.byteLength)); } + else if (Array.isArray(value)) { bytes = value.slice(); } + else bytes = []; + return _mkBuf(bytes); + }; + Buffer.alloc = function (n, fill) { var b = []; for (var i = 0; i < n; i++) b.push(typeof fill === "number" ? (fill & 255) : 0); return _mkBuf(b); }; + Buffer.isBuffer = function (x) { return !!(x && x.__isBuffer); }; + Buffer.byteLength = function (s, enc) { return Buffer.from(s, enc).length; }; + Buffer.concat = function (list) { var all = []; for (var i = 0; i < list.length; i++) { for (var j = 0; j < list[i].length; j++) all.push(list[i][j]); } return _mkBuf(all); }; + g.Buffer = Buffer; + + // ---- Blob ---- + function Blob(parts, opts) { + var bytes = []; parts = parts || []; + for (var i = 0; i < parts.length; i++) { + var p = parts[i]; + if (typeof p === "string") { var b = _u8(p); for (var j = 0; j < b.length; j++) bytes.push(b[j]); } + else if (p && p.__isBlob) { for (var n = 0; n < p.__bytes.length; n++) bytes.push(p.__bytes[n]); } + else if (p instanceof Uint8Array || (p && p.__isBuffer)) { for (var k = 0; k < p.length; k++) bytes.push(p[k]); } + else if (p instanceof ArrayBuffer) { var u = new Uint8Array(p); for (var m = 0; m < u.length; m++) bytes.push(u[m]); } + else { var s = _u8(String(p)); for (var q = 0; q < s.length; q++) bytes.push(s[q]); } + } + this.__isBlob = true; this.__bytes = bytes; this.size = bytes.length; this.type = (opts && opts.type) || ""; + } + Blob.prototype.text = function () { return Promise.resolve(_s8(this.__bytes)); }; + Blob.prototype.arrayBuffer = function () { return Promise.resolve(new Uint8Array(this.__bytes).buffer); }; + Blob.prototype.slice = function (s, e, type) { var b = this.__bytes.slice(s, e); var nb = new Blob([], {}); nb.__bytes = b; nb.size = b.length; nb.type = type || ""; return nb; }; + g.Blob = Blob; +})(${G}); +`; + +// ── URL ── URL + URLSearchParams (pure JS; QuickJS has no URL constructor). +const URL_API = String.raw` (function (g) { "use strict"; @@ -133,46 +262,59 @@ export const SANDBOX_POLYFILLS = String.raw` URL.prototype.toString = function () { return this.href; }; URL.prototype.toJSON = function () { return this.href; }; - // ---- TextEncoder / TextDecoder (UTF-8) ---- - function TextEncoder() {} - TextEncoder.prototype.encode = function (str) { - str = String(str === undefined ? "" : str); - var out = []; - for (var i = 0; i < str.length; i++) { - var c = str.charCodeAt(i); - if (c < 0x80) out.push(c); - else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); - else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) { - var c2 = str.charCodeAt(i + 1); - if (c2 >= 0xdc00 && c2 <= 0xdfff) { - var cp = 0x10000 + ((c - 0xd800) << 10) + (c2 - 0xdc00); - out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); - i++; - } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); - } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); - } - return new Uint8Array(out); - }; - function TextDecoder() {} - TextDecoder.prototype.decode = function (buf) { - if (!buf) return ""; - var bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf.buffer || buf); - var out = "", i = 0; - while (i < bytes.length) { - var b = bytes[i++]; - if (b < 0x80) out += String.fromCharCode(b); - else if (b >= 0xc0 && b < 0xe0) out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f)); - else if (b >= 0xe0 && b < 0xf0) out += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f)); - else { - var cp2 = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f); - cp2 -= 0x10000; - out += String.fromCharCode(0xd800 + (cp2 >> 10), 0xdc00 + (cp2 & 0x3ff)); - } - } - return out; - }; + g.URLSearchParams = URLSearchParams; + g.URL = URL; +})(${G}); +`; + +// ── HTTP_TYPES ── Headers, FormData, Request, Response (data holders used by NETWORK). +const HTTP_TYPES = String.raw` +(function (g) { + "use strict"; + + // ---- Headers ---- + function Headers(obj) { + this.__h = {}; + for (var k in (obj || {})) if (Object.prototype.hasOwnProperty.call(obj, k)) this.__h[String(k).toLowerCase()] = obj[k]; + } + Headers.prototype.get = function (k) { var v = this.__h[String(k).toLowerCase()]; return v == null ? null : v; }; + Headers.prototype.has = function (k) { return String(k).toLowerCase() in this.__h; }; + Headers.prototype.set = function (k, v) { this.__h[String(k).toLowerCase()] = v; }; + Headers.prototype.append = function (k, v) { this.__h[String(k).toLowerCase()] = v; }; + Headers.prototype["delete"] = function (k) { delete this.__h[String(k).toLowerCase()]; }; + Headers.prototype.forEach = function (cb, t) { for (var k in this.__h) cb.call(t, this.__h[k], k, this); }; + g.Headers = Headers; + + // ---- FormData ---- + function FormData() { this.__e = []; } + FormData.prototype.append = function (k, v, fn) { this.__e.push([String(k), v, fn]); }; + FormData.prototype.set = function (k, v, fn) { this["delete"](k); this.__e.push([String(k), v, fn]); }; + FormData.prototype.get = function (k) { k = String(k); for (var i = 0; i < this.__e.length; i++) if (this.__e[i][0] === k) return this.__e[i][1]; return null; }; + FormData.prototype.getAll = function (k) { k = String(k); var r = []; for (var i = 0; i < this.__e.length; i++) if (this.__e[i][0] === k) r.push(this.__e[i][1]); return r; }; + FormData.prototype.has = function (k) { return this.get(String(k)) !== null; }; + FormData.prototype["delete"] = function (k) { k = String(k); this.__e = this.__e.filter(function (e) { return e[0] !== k; }); }; + FormData.prototype.forEach = function (cb, t) { for (var i = 0; i < this.__e.length; i++) cb.call(t, this.__e[i][1], this.__e[i][0], this); }; + FormData.prototype.entries = function () { return this.__e.map(function (e) { return [e[0], e[1]]; }); }; + FormData.prototype.keys = function () { return this.__e.map(function (e) { return e[0]; }); }; + FormData.prototype.values = function () { return this.__e.map(function (e) { return e[1]; }); }; + g.FormData = FormData; - // ---- structuredClone ---- + // ---- Request / Response (data holders) ---- + function Request(input, init) { init = init || {}; this.url = (input && input.url) ? input.url : String(input); this.method = init.method || (input && input.method) || "GET"; this.headers = new g.Headers(init.headers || (input && input.headers) || {}); this.body = init.body != null ? init.body : (input && input.body); this.__isRequest = true; } + Request.prototype.clone = function () { return new Request(this, {}); }; + g.Request = Request; + function Response(body, init) { init = init || {}; this.__body = body == null ? "" : String(body); this.status = init.status != null ? init.status : 200; this.statusText = init.statusText || ""; this.ok = this.status >= 200 && this.status < 300; this.headers = new g.Headers(init.headers || {}); this.__isResponse = true; } + Response.prototype.text = function () { return Promise.resolve(this.__body); }; + Response.prototype.json = function () { var b = this.__body; return Promise.resolve(JSON.parse(b)); }; + Response.prototype.arrayBuffer = function () { return Promise.resolve(new Uint8Array(g.__rqb.u8(this.__body)).buffer); }; + g.Response = Response; +})(${G}); +`; + +// ── CLONE ── structuredClone (deep clone of JSON-ish + Date/RegExp/Map/Set, cyclic-safe). +const CLONE = String.raw` +(function (g) { + "use strict"; function structuredClone(value) { function cl(x, seen) { if (x === null || typeof x !== "object") return x; @@ -189,25 +331,17 @@ export const SANDBOX_POLYFILLS = String.raw` } return cl(value, new Map()); } - - g.URLSearchParams = URLSearchParams; - g.URL = URL; - g.TextEncoder = TextEncoder; - g.TextDecoder = TextDecoder; g.structuredClone = structuredClone; -})(typeof globalThis !== "undefined" ? globalThis : this); +})(${G}); `; -// In-guest shims for the HOST-BRIDGED capabilities (crypto, fetch). These call -// the host functions __hostCrypto (sync) / __hostFetch (async) that -// executeUserFunction installs. Only JSON strings cross the boundary — the host -// handlers receive a string and return a string (fetch via a guest promise the -// host resolves), so no host object is ever exposed to the rule code. -export const SANDBOX_BRIDGE_SHIMS = String.raw` +// ── CRYPTO ── real entropy/digests via the host's node:crypto (bridge: __hostCrypto). +// require('crypto') maps here; any other require(...) throws a guided error. +const CRYPTO = String.raw` (function (g) { "use strict"; + var B = g.__rqb, _u8 = B.u8, _toB64 = B.toB64, _fromHex = B.fromHex; - // ---- crypto (real entropy/digests via the host's node:crypto) ---- g.crypto = { randomUUID: function () { return JSON.parse(__hostCrypto(JSON.stringify({ op: "randomUUID" }))).uuid; @@ -218,234 +352,151 @@ export const SANDBOX_BRIDGE_SHIMS = String.raw` return arr; } }; - // node:crypto subset reachable via require('crypto') + + // ---- crypto.subtle.digest (webcrypto) — keyless hashing; binary-exact via base64 ---- + g.crypto.subtle = { + digest: function (algo, data) { + var name = (typeof algo === "string" ? algo : (algo && algo.name) || "SHA-256").toLowerCase().replace("-", ""); + var bytes; + if (typeof data === "string") bytes = _u8(data); + else if (data instanceof ArrayBuffer) bytes = Array.prototype.slice.call(new Uint8Array(data)); + else if (data && data.buffer) bytes = Array.prototype.slice.call(new Uint8Array(data.buffer, data.byteOffset || 0, data.byteLength)); + else bytes = Array.prototype.slice.call(data || []); + var hex = JSON.parse(__hostCrypto(JSON.stringify({ op: "hash", algo: name, data: _toB64(bytes), dataEncoding: "base64", encoding: "hex" }))).digest; + return Promise.resolve(new Uint8Array(_fromHex(hex)).buffer); + } + }; + + // ---- node:crypto subset (reachable via require('crypto')) ---- var nodeCrypto = { randomUUID: g.crypto.randomUUID, randomBytes: function (n) { - var r = JSON.parse(__hostCrypto(JSON.stringify({ op: "randomBytes", size: n }))); - return r.bytes; + return JSON.parse(__hostCrypto(JSON.stringify({ op: "randomBytes", size: n }))).bytes; }, createHash: function (algo) { var buf = ""; return { update: function (d) { buf += String(d); return this; }, - digest: function (enc) { - return JSON.parse(__hostCrypto(JSON.stringify({ op: "hash", algo: algo, data: buf, encoding: enc || "hex" }))).digest; - } + digest: function (enc) { return JSON.parse(__hostCrypto(JSON.stringify({ op: "hash", algo: algo, data: buf, encoding: enc || "hex" }))).digest; } + }; + }, + createHmac: function (algo, key) { + var buf = ""; + return { + update: function (d) { buf += String(d); return this; }, + digest: function (enc) { return JSON.parse(__hostCrypto(JSON.stringify({ op: "hmac", algo: algo, key: String(key), data: buf, encoding: enc || "hex" }))).digest; } }; } }; - // ---- require() — safe modules map to bridges; everything else is a guided error ---- g.require = function (name) { if (name === "crypto" || name === "node:crypto") return nodeCrypto; throw new Error("Cannot require('" + name + "') — modules are not available in sandboxed rules"); }; +})(${G}); +`; - // ---- fetch (real HTTP via the host; copied request/response only) ---- - function Headers(obj) { - this.__h = {}; - for (var k in (obj || {})) if (Object.prototype.hasOwnProperty.call(obj, k)) this.__h[String(k).toLowerCase()] = obj[k]; +// ── NETWORK ── fetch (single, body-aware) + XMLHttpRequest + WebSocket-guard +// (bridge: __hostFetch; http(s)-only + credentials:'omit' policy is enforced host-side). +const NETWORK = String.raw` +(function (g) { + "use strict"; + var _s8 = g.__rqb.s8; + + function _multipart(fd) { + var boundary = "----RQFormBoundary" + crypto.randomUUID().replace(/-/g, ""); + var CRLF = "\r\n", body = ""; + fd.__e.forEach(function (e) { + var name = e[0], val = e[1], fn = e[2]; + body += "--" + boundary + CRLF; + if (val && val.__isBlob) { body += 'Content-Disposition: form-data; name="' + name + '"' + (fn ? '; filename="' + fn + '"' : "") + CRLF; if (val.type) body += "Content-Type: " + val.type + CRLF; body += CRLF + _s8(val.__bytes) + CRLF; } + else { body += 'Content-Disposition: form-data; name="' + name + '"' + CRLF + CRLF + String(val) + CRLF; } + }); + body += "--" + boundary + "--" + CRLF; + return { body: body, contentType: "multipart/form-data; boundary=" + boundary }; } - Headers.prototype.get = function (k) { var v = this.__h[String(k).toLowerCase()]; return v == null ? null : v; }; - Headers.prototype.has = function (k) { return String(k).toLowerCase() in this.__h; }; - Headers.prototype.forEach = function (cb, t) { for (var k in this.__h) cb.call(t, this.__h[k], k, this); }; - g.Headers = Headers; - g.fetch = function (url, init) { + // One fetch: accepts a Request or url, normalises FormData/Blob/URLSearchParams + // bodies + Headers, marshals to the host bridge, returns a Response-like object. + g.fetch = function (input, init) { init = init || {}; - var hdrs = init.headers || {}; - if (hdrs && typeof hdrs.forEach === "function" && hdrs.__h) { var o = {}; hdrs.forEach(function (v, k) { o[k] = v; }); hdrs = o; } - var req = JSON.stringify({ - url: String(url), - method: init.method || "GET", - headers: hdrs, - body: init.body != null ? String(init.body) : undefined - }); + if (input instanceof g.Request) { init = { method: input.method, headers: input.headers, body: input.body }; input = input.url; } + var body = init.body, headers = init.headers || {}; + if (body && body.__isBlob) { body = _s8(body.__bytes); } + else if (body instanceof g.FormData) { var mp = _multipart(body); body = mp.body; var o = {}; if (headers && headers.forEach) { headers.forEach(function (v, k) { o[k] = v; }); } else { for (var k in headers) o[k] = headers[k]; } o["content-type"] = mp.contentType; headers = o; } + else if (body instanceof g.URLSearchParams) { body = body.toString(); var o2 = {}; for (var k2 in headers) o2[k2] = headers[k2]; if (!o2["content-type"] && !o2["Content-Type"]) o2["content-type"] = "application/x-www-form-urlencoded"; headers = o2; } + if (headers && typeof headers.forEach === "function" && headers.__h) { var oh = {}; headers.forEach(function (v, k) { oh[k] = v; }); headers = oh; } + var req = JSON.stringify({ url: String(input), method: init.method || "GET", headers: headers, body: body != null ? String(body) : undefined }); return __hostFetch(req).then(function (jsonStr) { var d = JSON.parse(jsonStr); if (d && d.__fetchError) throw new Error(d.__fetchError); return { status: d.status, statusText: d.statusText, ok: d.ok, url: d.url, - headers: new Headers(d.headers), + headers: new g.Headers(d.headers), text: function () { return Promise.resolve(d.body); }, json: function () { return Promise.resolve(JSON.parse(d.body)); } }; }); }; -})(typeof globalThis !== "undefined" ? globalThis : this); -`; - -// Additional Web/Node APIs layered on top of the polyfills + bridges. Pure-JS -// where possible (Buffer, timers, Blob, FormData, Request/Response, performance); -// XHR rides the fetch bridge; createHmac/subtle.digest ride the crypto bridge. -// MUST be concatenated AFTER SANDBOX_POLYFILLS + SANDBOX_BRIDGE_SHIMS (uses -// TextEncoder/Headers/URLSearchParams, fetch, __hostCrypto, atob/btoa). -export const SANDBOX_EXTRA_SHIMS = String.raw` -(function (g) { - "use strict"; - var _hex = "0123456789abcdef"; - function _u8(s){ return Array.prototype.slice.call(new TextEncoder().encode(String(s))); } - function _s8(b){ return new TextDecoder().decode(new Uint8Array(b)); } - function _toHex(b){ var o=""; for(var i=0;i>4)&15]+_hex[b[i]&15]; } return o; } - function _fromHex(s){ s=String(s); var o=[]; for(var i=0;i+1=200&&this.status<300; this.headers=new g.Headers(init.headers||{}); this.__isResponse=true; } - Response.prototype.text=function(){ return Promise.resolve(this.__body); }; - Response.prototype.json=function(){ var b=this.__body; return Promise.resolve(JSON.parse(b)); }; - Response.prototype.arrayBuffer=function(){ return Promise.resolve(new Uint8Array(_u8(this.__body)).buffer); }; - g.Response = Response; - - // ---- fetch augmentation: Request input + FormData/Blob/URLSearchParams bodies ---- - var __origFetch = g.fetch; - function _multipart(fd){ - var boundary = "----RQFormBoundary" + crypto.randomUUID().replace(/-/g,""); - var CRLF = "\r\n", body = ""; - fd.__e.forEach(function(e){ var name=e[0], val=e[1], fn=e[2]; - body += "--"+boundary+CRLF; - if(val && val.__isBlob){ body += 'Content-Disposition: form-data; name="'+name+'"'+(fn?'; filename="'+fn+'"':'')+CRLF; if(val.type) body+="Content-Type: "+val.type+CRLF; body+=CRLF+_s8(val.__bytes)+CRLF; } - else { body += 'Content-Disposition: form-data; name="'+name+'"'+CRLF+CRLF+String(val)+CRLF; } - }); - body += "--"+boundary+"--"+CRLF; - return { body: body, contentType: "multipart/form-data; boundary="+boundary }; - } - g.fetch = function(input, init){ - init = init || {}; - if (input instanceof Request){ init={ method: input.method, headers: input.headers, body: input.body }; input = input.url; } - var body = init.body, headers = init.headers || {}; - if (body && body.__isBlob){ body = _s8(body.__bytes); } - else if (body instanceof FormData){ var mp=_multipart(body); body=mp.body; var o={}; if(headers&&headers.forEach){ headers.forEach(function(v,k){o[k]=v;}); } else { for(var k in headers) o[k]=headers[k]; } o["content-type"]=mp.contentType; headers=o; } - else if (body instanceof g.URLSearchParams){ body=body.toString(); var o2={}; for(var k2 in headers) o2[k2]=headers[k2]; if(!o2["content-type"]&&!o2["Content-Type"]) o2["content-type"]="application/x-www-form-urlencoded"; headers=o2; } - var init2={}; for(var kk in init) init2[kk]=init[kk]; init2.body=body; init2.headers=headers; - return __origFetch(input, init2); - }; // ---- XMLHttpRequest (async only; sync throws) over the fetch bridge ---- - function XMLHttpRequest(){ this.readyState=0; this.status=0; this.statusText=""; this.responseText=""; this.response=""; this.responseType=""; this._h={}; this._m="GET"; this._u=""; this._rh={}; this.onreadystatechange=null; this.onload=null; this.onerror=null; this.onloadend=null; } - XMLHttpRequest.UNSENT=0; XMLHttpRequest.OPENED=1; XMLHttpRequest.HEADERS_RECEIVED=2; XMLHttpRequest.LOADING=3; XMLHttpRequest.DONE=4; - XMLHttpRequest.prototype.open=function(method,url,async){ if(async===false) throw new Error("Synchronous XMLHttpRequest is not supported in sandboxed rules; use async or fetch()."); this._m=method||"GET"; this._u=String(url); this.readyState=1; if(this.onreadystatechange) this.onreadystatechange(); }; - XMLHttpRequest.prototype.setRequestHeader=function(k,v){ this._h[k]=v; }; - XMLHttpRequest.prototype.getAllResponseHeaders=function(){ var s=""; for(var k in this._rh) s+=k+": "+this._rh[k]+"\r\n"; return s; }; - XMLHttpRequest.prototype.getResponseHeader=function(k){ k=String(k).toLowerCase(); return (k in this._rh)?this._rh[k]:null; }; - XMLHttpRequest.prototype.abort=function(){}; - XMLHttpRequest.prototype.send=function(body){ var self=this; - g.fetch(this._u,{ method:this._m, headers:this._h, body:body }).then(function(res){ self.status=res.status; self.statusText=res.statusText||""; self._rh={}; if(res.headers&&res.headers.forEach) res.headers.forEach(function(v,k){ self._rh[String(k).toLowerCase()]=v; }); return res.text(); }) - .then(function(text){ self.responseText=text; self.response=(self.responseType==="json")?(function(){try{return JSON.parse(text);}catch(e){return null;}})():text; self.readyState=4; if(self.onreadystatechange) self.onreadystatechange(); if(self.onload) self.onload(); if(self.onloadend) self.onloadend(); }) - .catch(function(e){ self.readyState=4; if(self.onreadystatechange) self.onreadystatechange(); if(self.onerror) self.onerror(e); if(self.onloadend) self.onloadend(); }); + function XMLHttpRequest() { this.readyState = 0; this.status = 0; this.statusText = ""; this.responseText = ""; this.response = ""; this.responseType = ""; this._h = {}; this._m = "GET"; this._u = ""; this._rh = {}; this.onreadystatechange = null; this.onload = null; this.onerror = null; this.onloadend = null; } + XMLHttpRequest.UNSENT = 0; XMLHttpRequest.OPENED = 1; XMLHttpRequest.HEADERS_RECEIVED = 2; XMLHttpRequest.LOADING = 3; XMLHttpRequest.DONE = 4; + XMLHttpRequest.prototype.open = function (method, url, async) { if (async === false) throw new Error("Synchronous XMLHttpRequest is not supported in sandboxed rules; use async or fetch()."); this._m = method || "GET"; this._u = String(url); this.readyState = 1; if (this.onreadystatechange) this.onreadystatechange(); }; + XMLHttpRequest.prototype.setRequestHeader = function (k, v) { this._h[k] = v; }; + XMLHttpRequest.prototype.getAllResponseHeaders = function () { var s = ""; for (var k in this._rh) s += k + ": " + this._rh[k] + "\r\n"; return s; }; + XMLHttpRequest.prototype.getResponseHeader = function (k) { k = String(k).toLowerCase(); return (k in this._rh) ? this._rh[k] : null; }; + XMLHttpRequest.prototype.abort = function () {}; + XMLHttpRequest.prototype.send = function (body) { + var self = this; + g.fetch(this._u, { method: this._m, headers: this._h, body: body }).then(function (res) { self.status = res.status; self.statusText = res.statusText || ""; self._rh = {}; if (res.headers && res.headers.forEach) res.headers.forEach(function (v, k) { self._rh[String(k).toLowerCase()] = v; }); return res.text(); }) + .then(function (text) { self.responseText = text; self.response = (self.responseType === "json") ? (function () { try { return JSON.parse(text); } catch (e) { return null; } })() : text; self.readyState = 4; if (self.onreadystatechange) self.onreadystatechange(); if (self.onload) self.onload(); if (self.onloadend) self.onloadend(); }) + .catch(function (e) { self.readyState = 4; if (self.onreadystatechange) self.onreadystatechange(); if (self.onerror) self.onerror(e); if (self.onloadend) self.onloadend(); }); }; g.XMLHttpRequest = XMLHttpRequest; // ---- WebSocket: unsupported (a persistent connection can't outlive a per-request execution) ---- - g.WebSocket = function(){ throw new Error("WebSocket is not available in sandboxed rules (no persistent connections)."); }; + g.WebSocket = function () { throw new Error("WebSocket is not available in sandboxed rules (no persistent connections)."); }; +})(${G}); +`; - // ---- crypto: createHmac (node) + subtle.digest (webcrypto) ---- - var __nc = (function(){ try { return g.require("crypto"); } catch(e){ return null; } })(); - if (__nc){ - __nc.createHmac = function(algo, key){ var buf=""; - return { update:function(d){ buf+=String(d); return this; }, digest:function(enc){ return JSON.parse(__hostCrypto(JSON.stringify({op:"hmac",algo:algo,key:String(key),data:buf,encoding:enc||"hex"}))).digest; } }; - }; - } - if (g.crypto && !g.crypto.subtle){ - g.crypto.subtle = { - digest: function(algo, data){ - var name=(typeof algo==="string"?algo:(algo&&algo.name)||"SHA-256").toLowerCase().replace("-",""); - var bytes; - if(typeof data==="string") bytes=_u8(data); - else if(data instanceof ArrayBuffer) bytes=Array.prototype.slice.call(new Uint8Array(data)); - else if(data && data.buffer) bytes=Array.prototype.slice.call(new Uint8Array(data.buffer, data.byteOffset||0, data.byteLength)); - else bytes=Array.prototype.slice.call(data||[]); - var hex=JSON.parse(__hostCrypto(JSON.stringify({op:"hash",algo:name,data:_toB64(bytes),dataEncoding:"base64",encoding:"hex"}))).digest; - return Promise.resolve(new Uint8Array(_fromHex(hex)).buffer); - } - }; - } -})(typeof globalThis !== "undefined" ? globalThis : this); +// ── TIMERS ── setTimeout honors the real delay via __hostTimer (clamped host-side +// to the execution budget); setInterval is a no-op (a repeating timer can't outlive +// a per-request execution); queueMicrotask + performance are pure. +const TIMERS = String.raw` +(function (g) { + "use strict"; + var _cancelled = {}, _tid = 0; + g.setTimeout = function (fn, ms) { + var id = ++_tid; var args = Array.prototype.slice.call(arguments, 2); + __hostTimer(Number(ms) || 0).then(function () { if (!_cancelled[id] && typeof fn === "function") fn.apply(null, args); }); + return id; + }; + g.clearTimeout = function (id) { _cancelled[id] = true; }; + g.setInterval = function () { return ++_tid; }; + g.clearInterval = function () {}; + g.queueMicrotask = function (fn) { Promise.resolve().then(fn); }; + g.performance = g.performance || { now: function () { return Date.now(); }, timeOrigin: 0 }; +})(${G}); `; + +// ── HARNESS ── the run environment. MUST be top-level (not an IIFE) so `console`, +// `args`, `$sharedState`, `__OUTPUT` are script-globals the user-fn wrapper reads. +// Reads the host-injected `__argsJson`/`__sharedStateJson`. ';'-separated, no +// '//' comments, so it concatenates safely. +const HARNESS = [ + "var __logs = [];", + "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(""); + +/** + * The complete in-guest prelude, concatenated in dependency order. index.ts + * appends the user-function wrapper after this. + */ +export const SANDBOX_PRELUDE = + ENCODING + BINARY + URL_API + HTTP_TYPES + CLONE + CRYPTO + NETWORK + TIMERS + HARNESS; From 38cee65a96f0012bde34991dccbf8ae9aebe4b2c Mon Sep 17 00:00:00 2001 From: dinex-dev Date: Fri, 26 Jun 2026 13:30:48 +0530 Subject: [PATCH 8/9] fix(sandbox): surface + report errors instead of swallowing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously any sandbox failure was swallowed: a prelude (our shim) error was disposed silently, user throws/timeouts degraded to a generic "Returned value is not a string", nothing reached host logs/Sentry, and synchronous user throws leaked out as untracked top-level eval errors. A regressed polyfill would have been invisible in production. Now executeUserFunction categorises and surfaces every failure: - Eval the prelude (our shims) SEPARATELY from the user wrapper, so a shim error is unambiguously ours (kind "prelude") and is dumped, not disposed. - Run the user fn inside a `.then` so SYNC throws become rejections captured by `.catch` (→ __OUTPUT.error) like async ones, instead of leaking out. - Throw a typed SandboxError { kind: "prelude" | "user" | "timeout" } carrying the real message; success still returns the result string. A CPU-deadline interrupt is classified as "timeout" (not a user logic error). - reportSandboxError(): always console.error("[rq-sandbox]", kind, msg); prelude/ timeout also go to Sentry (guarded — may be uninitialised). user → console only. Processors now show the real error and a distinct code: 188 = sandbox-internal (our bug), 187 = the rule author's code. Verified: prelude error reported (not swallowed); user sync + async throws surface the real message; infinite loop → timeout; happy paths for all APIs unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../processors/modify_request_processor.js | 9 +- .../processors/modify_response_processor.js | 9 +- dist/utils/index.d.ts | 9 ++ dist/utils/index.js | 126 +++++++++++++++--- .../processors/modify_request_processor.js | 9 +- .../processors/modify_response_processor.js | 9 +- src/utils/index.ts | 108 ++++++++++++--- 7 files changed, 239 insertions(+), 40 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 129d49b..c00e064 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 @@ -59,8 +59,13 @@ const modify_request_using_code = async (action, ctx) => { throw new Error("Returned value is not a string"); } catch (error) { - // Function parsed but failed to execute - return modify_request(ctx, "Can't execute Requestly function. Please recheck. Error Code 187. Actual Error: " + + // Function parsed but failed to execute. Code 188 = sandbox-internal (our shim + // broke); 187 = the rule author's code. error.message now carries the real + // sandbox error (previously swallowed). + const code = error && error.kind === "prelude" ? 188 : 187; + return modify_request(ctx, "Can't execute Requestly function. Please recheck. Error Code " + + code + + ". Actual Error: " + error.message); } }; 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 96a204a..7c5b37f 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 @@ -139,8 +139,13 @@ const modify_response_using_code = async (action, ctx) => { throw new Error("Returned value is not a string"); } catch (error) { - // Function parsed but failed to execute - return modify_response(ctx, "Can't execute Requestly function. Please recheck. Error Code 187. Actual Error: " + + // Function parsed but failed to execute. Code 188 = sandbox-internal (our shim + // broke); 187 = the rule author's code. error.message now carries the real + // sandbox error (previously swallowed). + const code = error && error.kind === "prelude" ? 188 : 187; + return modify_response(ctx, "Can't execute Requestly function. Please recheck. Error Code " + + code + + ". Actual Error: " + error.message); } }; diff --git a/dist/utils/index.d.ts b/dist/utils/index.d.ts index 30ded58..1172b2e 100644 --- a/dist/utils/index.d.ts +++ b/dist/utils/index.d.ts @@ -1,3 +1,12 @@ +/** + * Where a sandbox failure originated, so callers + telemetry can tell OUR + * shim/infra bugs (`prelude`) from the rule author's (`user`) and timeouts apart. + */ +export type SandboxErrorKind = "prelude" | "user" | "timeout"; +export declare class SandboxError extends Error { + kind: SandboxErrorKind; + constructor(message: string, kind: SandboxErrorKind); +} /** * Verify a rule's code string parses WITHOUT executing it. Constructing * `new Function(body)` compiles/parses the body but never runs it (the function diff --git a/dist/utils/index.js b/dist/utils/index.js index b4e807a..f9a03cf 100644 --- a/dist/utils/index.js +++ b/dist/utils/index.js @@ -1,9 +1,32 @@ "use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.isValidFunctionString = void 0; +exports.isValidFunctionString = exports.SandboxError = void 0; exports.executeUserFunction = executeUserFunction; const quickjs_singlefile_cjs_release_sync_1 = __importDefault(require("@jitl/quickjs-singlefile-cjs-release-sync")); // Import from quickjs-emscripten-core (lean, bring-your-own-variant) rather than @@ -13,7 +36,50 @@ const quickjs_singlefile_cjs_release_sync_1 = __importDefault(require("@jitl/qui // bundler-safe. (Same dependency choice as @requestly/sandbox-node.) const quickjs_emscripten_core_1 = require("quickjs-emscripten-core"); const crypto_1 = require("crypto"); +const Sentry = __importStar(require("@sentry/browser")); const state_1 = __importDefault(require("../components/proxy-middleware/middlewares/state")); +class SandboxError extends Error { + constructor(message, kind) { + super(message); + this.name = "SandboxError"; + this.kind = kind; + } +} +exports.SandboxError = SandboxError; +/** Read a QuickJS error handle out as a host string (best-effort: name + message). */ +function dumpError(vm, handle) { + var _a; + try { + const d = vm.dump(handle); + if (d && typeof d === "object") { + return String((d.name ? d.name + ": " : "") + ((_a = d.message) !== null && _a !== void 0 ? _a : JSON.stringify(d))); + } + return String(d); + } + catch (_b) { + return "unknown sandbox error"; + } +} +/** + * Host-side visibility for sandbox failures (previously these were swallowed). + * `prelude`/`timeout` are OUR problem → always report to Sentry; `user` is the + * rule author's → console only, to avoid telemetry noise. Sentry is wrapped + * because it may be uninitialised in this context (CLI/tests). + */ +function reportSandboxError(kind, message) { + // eslint-disable-next-line no-console + console.error("[rq-sandbox]", kind, message); + if (kind === "user") + return; + try { + Sentry.captureException(new Error("[rq-sandbox:" + kind + "] " + message), { + tags: { sandbox: kind }, + }); + } + catch (_a) { + /* Sentry not initialised — the console.error above is the fallback */ + } +} const sandbox_globals_1 = require("./sandbox-globals"); /** * RQ-2426: rule-supplied "code" rules (Modify Request/Response) used to be run @@ -278,10 +344,23 @@ async function executeUserFunction(ctx, functionString, args) { // 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_globals_1.SANDBOX_PRELUDE + - "Promise.resolve((" + + // 1) Eval our prelude (shims) ON ITS OWN. An error here is OUR bug, not the + // rule author's — dump + report it instead of swallowing it as a generic 187. + const preludeResult = vm.evalCode(sandbox_globals_1.SANDBOX_PRELUDE); + if (preludeResult.error) { + const msg = dumpError(vm, preludeResult.error); + preludeResult.error.dispose(); + reportSandboxError("prelude", msg); + throw new SandboxError(msg, "prelude"); + } + preludeResult.value.dispose(); + // 2) Eval the user fn wrapper. Running the fn inside a `.then` turns a SYNC + // throw into a rejection, so it is captured by `.catch` (→ __OUTPUT.error) + // exactly like an async throw — instead of leaking out as a top-level eval + // error that we'd lose. + const userProgram = "Promise.resolve().then(function () { return (" + functionString + - "\n)(args)).then(function (r) {" + + "\n)(args); }).then(function (r) {" + " var out;" + " if (r === undefined || r === null) { out = r; }" + ' else if (typeof r === "object") { out = JSON.stringify(r); }' + @@ -290,14 +369,16 @@ async function executeUserFunction(ctx, functionString, args) { "}).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; + const userEval = vm.evalCode(userProgram); + if (userEval.error) { + // Setting up the chain itself failed (e.g. a syntax issue isValidFunctionString + // missed). Surface the real message rather than dropping it. + const msg = dumpError(vm, userEval.error); + userEval.error.dispose(); + reportSandboxError("user", msg); + throw new SandboxError(msg, "user"); } - // Success variant — dispose the completion value (we read __OUTPUT instead). - evalResult.value.dispose(); + userEval.value.dispose(); // Pump loop — drive the user fn's promise chain, including real async host // I/O (fetch). Re-arm the per-step CPU interrupt each iteration so a slow // network wait doesn't make a post-fetch sync burst trip the original @@ -327,24 +408,37 @@ async function executeUserFunction(ctx, functionString, args) { ]); } if (typeof output !== "string") { - // Promise never settled (timeout / never-resolving await) → no modification. - return undefined; + // No __OUTPUT and nothing left to await → timed out / never settled. + reportSandboxError("timeout", "rule execution timed out or never settled"); + throw new SandboxError("Execution timed out", "timeout"); } let parsed; try { parsed = JSON.parse(output); } catch (_h) { - return undefined; + // We control the marshaling, so malformed __OUTPUT is our bug. + const msg = "sandbox produced invalid output"; + reportSandboxError("prelude", msg); + throw new SandboxError(msg, "prelude"); } 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) { + // A CPU-deadline interrupt surfaces as a caught guest error ("interrupted") — + // classify that as a timeout, not the rule author's logic error. Everything + // else is a genuine user throw (sync or async), now surfaced (was swallowed). + const interrupted = /interrupt/i.test(String(parsed.error)); + const kind = interrupted ? "timeout" : "user"; + const message = interrupted + ? "Execution timed out (CPU limit)" + : String(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)] }); + ctx.rq.consoleLogs.push({ type: "error", args: [message] }); } - return undefined; + reportSandboxError(kind, message); + throw new SandboxError(message, kind); } // Write back any mutations the rule made to $sharedState. state_1.default.getInstance().setSharedState((_e = parsed.sharedState) !== null && _e !== void 0 ? _e : {}); 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 79cbfcc..0998fca 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 @@ -69,10 +69,15 @@ const modify_request_using_code = async (action, ctx) => { return modify_request(ctx, finalRequest); } else throw new Error("Returned value is not a string"); } catch (error) { - // Function parsed but failed to execute + // Function parsed but failed to execute. Code 188 = sandbox-internal (our shim + // broke); 187 = the rule author's code. error.message now carries the real + // sandbox error (previously swallowed). + const code = error && error.kind === "prelude" ? 188 : 187; return modify_request( ctx, - "Can't execute Requestly function. Please recheck. Error Code 187. Actual Error: " + + "Can't execute Requestly function. Please recheck. Error Code " + + code + + ". Actual Error: " + error.message ); } 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 2c33e38..8c22fb5 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 @@ -163,10 +163,15 @@ const modify_response_using_code = async (action, ctx) => { return modify_response(ctx, finalResponse, action.statusCode); } else throw new Error("Returned value is not a string"); } catch (error) { - // Function parsed but failed to execute + // Function parsed but failed to execute. Code 188 = sandbox-internal (our shim + // broke); 187 = the rule author's code. error.message now carries the real + // sandbox error (previously swallowed). + const code = error && error.kind === "prelude" ? 188 : 187; return modify_response( ctx, - "Can't execute Requestly function. Please recheck. Error Code 187. Actual Error: " + + "Can't execute Requestly function. Please recheck. Error Code " + + code + + ". Actual Error: " + error.message ); } diff --git a/src/utils/index.ts b/src/utils/index.ts index a719ccc..de6c29c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -10,7 +10,54 @@ import { QuickJSWASMModule, } from "quickjs-emscripten-core"; import { randomUUID, randomBytes, createHash, createHmac } from "crypto"; +import * as Sentry from "@sentry/browser"; import GlobalStateProvider from "../components/proxy-middleware/middlewares/state"; + +/** + * Where a sandbox failure originated, so callers + telemetry can tell OUR + * shim/infra bugs (`prelude`) from the rule author's (`user`) and timeouts apart. + */ +export type SandboxErrorKind = "prelude" | "user" | "timeout"; +export class SandboxError extends Error { + kind: SandboxErrorKind; + constructor(message: string, kind: SandboxErrorKind) { + super(message); + this.name = "SandboxError"; + this.kind = kind; + } +} + +/** Read a QuickJS error handle out as a host string (best-effort: name + message). */ +function dumpError(vm: any, handle: any): string { + try { + const d = vm.dump(handle); + if (d && typeof d === "object") { + return String((d.name ? d.name + ": " : "") + (d.message ?? JSON.stringify(d))); + } + return String(d); + } catch { + return "unknown sandbox error"; + } +} + +/** + * Host-side visibility for sandbox failures (previously these were swallowed). + * `prelude`/`timeout` are OUR problem → always report to Sentry; `user` is the + * rule author's → console only, to avoid telemetry noise. Sentry is wrapped + * because it may be uninitialised in this context (CLI/tests). + */ +function reportSandboxError(kind: SandboxErrorKind, message: string): void { + // eslint-disable-next-line no-console + console.error("[rq-sandbox]", kind, message); + if (kind === "user") return; + try { + Sentry.captureException(new Error("[rq-sandbox:" + kind + "] " + message), { + tags: { sandbox: kind }, + } as any); + } catch { + /* Sentry not initialised — the console.error above is the fallback */ + } +} import { SANDBOX_PRELUDE } from "./sandbox-globals"; /** @@ -304,11 +351,25 @@ export async function executeUserFunction( // 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_PRELUDE + - "Promise.resolve((" + + // 1) Eval our prelude (shims) ON ITS OWN. An error here is OUR bug, not the + // rule author's — dump + report it instead of swallowing it as a generic 187. + const preludeResult = vm.evalCode(SANDBOX_PRELUDE); + if (preludeResult.error) { + const msg = dumpError(vm, preludeResult.error); + preludeResult.error.dispose(); + reportSandboxError("prelude", msg); + throw new SandboxError(msg, "prelude"); + } + (preludeResult as { value: { dispose(): void } }).value.dispose(); + + // 2) Eval the user fn wrapper. Running the fn inside a `.then` turns a SYNC + // throw into a rejection, so it is captured by `.catch` (→ __OUTPUT.error) + // exactly like an async throw — instead of leaking out as a top-level eval + // error that we'd lose. + const userProgram = + "Promise.resolve().then(function () { return (" + functionString + - "\n)(args)).then(function (r) {" + + "\n)(args); }).then(function (r) {" + " var out;" + " if (r === undefined || r === null) { out = r; }" + ' else if (typeof r === "object") { out = JSON.stringify(r); }' + @@ -318,14 +379,16 @@ export async function executeUserFunction( " __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; + const userEval = vm.evalCode(userProgram); + if (userEval.error) { + // Setting up the chain itself failed (e.g. a syntax issue isValidFunctionString + // missed). Surface the real message rather than dropping it. + const msg = dumpError(vm, userEval.error); + userEval.error.dispose(); + reportSandboxError("user", msg); + throw new SandboxError(msg, "user"); } - // Success variant — dispose the completion value (we read __OUTPUT instead). - (evalResult as { value: { dispose(): void } }).value.dispose(); + (userEval as { value: { dispose(): void } }).value.dispose(); // Pump loop — drive the user fn's promise chain, including real async host // I/O (fetch). Re-arm the per-step CPU interrupt each iteration so a slow @@ -357,15 +420,19 @@ export async function executeUserFunction( } if (typeof output !== "string") { - // Promise never settled (timeout / never-resolving await) → no modification. - return undefined; + // No __OUTPUT and nothing left to await → timed out / never settled. + reportSandboxError("timeout", "rule execution timed out or never settled"); + throw new SandboxError("Execution timed out", "timeout"); } let parsed: any; try { parsed = JSON.parse(output); } catch { - return undefined; + // We control the marshaling, so malformed __OUTPUT is our bug. + const msg = "sandbox produced invalid output"; + reportSandboxError("prelude", msg); + throw new SandboxError(msg, "prelude"); } if (parsed.logs?.length && ctx?.rq?.consoleLogs) { @@ -373,10 +440,19 @@ export async function executeUserFunction( } if (parsed.error) { + // A CPU-deadline interrupt surfaces as a caught guest error ("interrupted") — + // classify that as a timeout, not the rule author's logic error. Everything + // else is a genuine user throw (sync or async), now surfaced (was swallowed). + const interrupted = /interrupt/i.test(String(parsed.error)); + const kind: SandboxErrorKind = interrupted ? "timeout" : "user"; + const message = interrupted + ? "Execution timed out (CPU limit)" + : String(parsed.error); if (ctx?.rq?.consoleLogs) { - ctx.rq.consoleLogs.push({ type: "error", args: [String(parsed.error)] }); + ctx.rq.consoleLogs.push({ type: "error", args: [message] }); } - return undefined; + reportSandboxError(kind, message); + throw new SandboxError(message, kind); } // Write back any mutations the rule made to $sharedState. From 2200e76c25a662c01d5fc00735ab549a72a320d9 Mon Sep 17 00:00:00 2001 From: dinex-dev Date: Fri, 26 Jun 2026 16:46:38 +0530 Subject: [PATCH 9/9] fix(sandbox): require('crypto').randomBytes returns a Buffer The host bridge returns the random bytes as a plain JSON array (only data crosses the sandbox boundary), so randomBytes(n) handed the guest a plain Array and randomBytes(16).toString('hex') produced comma-joined decimals instead of hex. Wrap the crossed-over bytes in the guest Buffer shim so the return type matches Node (toString('hex'/'base64'/'utf8') work). Entropy is unchanged (real host node:crypto); only the guest-side container type is restored. Addresses CodeRabbit review on PR #112. Co-Authored-By: Claude Opus 4.8 (1M context) --- dist/utils/sandbox-globals.js | 4 +++- src/utils/sandbox-globals.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/dist/utils/sandbox-globals.js b/dist/utils/sandbox-globals.js index d526ccd..ae0563d 100644 --- a/dist/utils/sandbox-globals.js +++ b/dist/utils/sandbox-globals.js @@ -367,7 +367,9 @@ const CRYPTO = String.raw ` var nodeCrypto = { randomUUID: g.crypto.randomUUID, randomBytes: function (n) { - return JSON.parse(__hostCrypto(JSON.stringify({ op: "randomBytes", size: n }))).bytes; + // Host returns the bytes as a plain array (only data crosses the boundary); + // wrap in the guest Buffer so Node-style randomBytes(n).toString('hex'/'base64') works. + return Buffer.from(JSON.parse(__hostCrypto(JSON.stringify({ op: "randomBytes", size: n }))).bytes); }, createHash: function (algo) { var buf = ""; diff --git a/src/utils/sandbox-globals.ts b/src/utils/sandbox-globals.ts index ee2b7e7..85f8a29 100644 --- a/src/utils/sandbox-globals.ts +++ b/src/utils/sandbox-globals.ts @@ -371,7 +371,9 @@ const CRYPTO = String.raw` var nodeCrypto = { randomUUID: g.crypto.randomUUID, randomBytes: function (n) { - return JSON.parse(__hostCrypto(JSON.stringify({ op: "randomBytes", size: n }))).bytes; + // Host returns the bytes as a plain array (only data crosses the boundary); + // wrap in the guest Buffer so Node-style randomBytes(n).toString('hex'/'base64') works. + return Buffer.from(JSON.parse(__hostCrypto(JSON.stringify({ op: "randomBytes", size: n }))).bytes); }, createHash: function (algo) { var buf = "";