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..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 @@ -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); } @@ -66,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 2c54af6..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 @@ -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."); } @@ -146,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 d385ba0..1172b2e 100644 --- a/dist/utils/index.d.ts +++ b/dist/utils/index.d.ts @@ -1,2 +1,18 @@ -export declare const getFunctionFromString: (functionStringEscaped: any) => any; +/** + * 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 + * 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..f9a03cf 100644 --- a/dist/utils/index.js +++ b/dist/utils/index.js @@ -1,46 +1,452 @@ "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.getFunctionFromString = void 0; +exports.isValidFunctionString = exports.SandboxError = 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")); +// 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 Sentry = __importStar(require("@sentry/browser")); 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}`)(); +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 + * 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. + * + * 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; // 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 +// executions. A fresh QuickJS *context* is created per execution for isolation. +let modulePromise = null; +function getQuickJSModule() { + if (!modulePromise) { + modulePromise = (0, quickjs_emscripten_core_1.newQuickJSWASMModuleFromVariant)(quickjs_singlefile_cjs_release_sync_1.default); + } + return modulePromise; +} +/** + * 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; +// ── 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) { - 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 = "{}"; + 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 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_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); + argsHandle.dispose(); + const sharedHandle = vm.newString(sharedStateJson); + vm.setProp(vm.global, "__sharedStateJson", sharedHandle); + sharedHandle.dispose(); + // 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; + 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(); + // 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. + // 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) {" + + " 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 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"); + } + 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 + // deadline; the overall wall-clock cap bounds total time. Repeat until the + // top-level promise sets __OUTPUT, the deadline trips, or nothing is pending. + 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") { + // 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) { + // 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: [message] }); + } + 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 : {}); + // 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/dist/utils/sandbox-globals.d.ts b/dist/utils/sandbox-globals.d.ts new file mode 100644 index 0000000..cd47307 --- /dev/null +++ b/dist/utils/sandbox-globals.d.ts @@ -0,0 +1,32 @@ +/** + * 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. + */ +/** + * 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 new file mode 100644 index 0000000..ae0563d --- /dev/null +++ b/dist/utils/sandbox-globals.js @@ -0,0 +1,495 @@ +"use strict"; +/** + * 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_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"; + + // ---- 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; }; + + 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; + + // ---- 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; + 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.structuredClone = structuredClone; +})(${G}); +`; +// ── 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; + + 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; + } + }; + + // ---- 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) { + // 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 = ""; + 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; } + }; + }, + 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; } + }; + } + }; + + 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; + + 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 }; + } + + // 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 || {}; + 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 g.Headers(d.headers), + text: function () { return Promise.resolve(d.body); }, + json: function () { return Promise.resolve(JSON.parse(d.body)); } + }; + }); + }; + + // ---- 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)."); }; +})(${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/package-lock.json b/package-lock.json index b843677..bc187c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.5.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-core": "^0.32.0", "semaphore": "^1.1.0", "ua-parser-js": "^1.0.37", "url": "^0.11.3", @@ -85,6 +87,21 @@ "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/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3892,6 +3909,15 @@ } ] }, + "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..7e7a0f6 100644 --- a/package.json +++ b/package.json @@ -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-core": "^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..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 @@ -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,16 +63,21 @@ 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); } 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 223a0c4..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 @@ -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, @@ -173,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 7b5af9b..de6c29c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,54 +1,467 @@ -import { types } from "util"; -import ConsoleCapture from "capture-console-logs"; +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-core"; +import { randomUUID, randomBytes, createHash, createHmac } from "crypto"; +import * as Sentry from "@sentry/browser"; 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}`)(); -}; +/** + * 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"; -/* Expects that the functionString has already been validated to be representing a proper function */ -export async function executeUserFunction(ctx, functionString: string, args) { +/** + * 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. + * + * 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 generateFunctionWithSharedState = function (functionStringEscaped) { +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; - 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); +// 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; +} + +/** + * 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; + } +}; + +// ── 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, + functionString: string, + args: any +): Promise { + let argsJson = "{}"; + 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() ?? {} + ); + } catch { + sharedStateJson = "{}"; + } + + 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( + shouldInterruptAfterDeadline(Date.now() + EXEC_TIMEOUT_MS) + ); - const {func: generatedFunction, updatedSharedState} = generateFunctionWithSharedState(functionString); - - const consoleCapture = new ConsoleCapture() - consoleCapture.start(true) + // 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(); - let finalResponse = generatedFunction(args); + // In-flight async host calls (fetch, timers) the pump loop must await before + // the guest's await-chain can progress. + const inflight: Promise[] = []; + // 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; - if (types.isPromise(finalResponse)) { - finalResponse = await finalResponse; + // 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(); + + // 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. + // 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(); - consoleCapture.stop() - const consoleLogs = consoleCapture.getCaptures() - - ctx.rq.consoleLogs.push(...consoleLogs) + // 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) {" + + " 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 });" + + "});"; - /** - * 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); + 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"); + } + (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 + // 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. + 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(); + + 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 finalResponse === "object") { - finalResponse = JSON.stringify(finalResponse); + if (typeof output !== "string") { + // 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 { + // 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) { + 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: 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: [message] }); } + reportSandboxError(kind, message); + throw new SandboxError(message, kind); + } + + // 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(); + } +} diff --git a/src/utils/sandbox-globals.ts b/src/utils/sandbox-globals.ts new file mode 100644 index 0000000..85f8a29 --- /dev/null +++ b/src/utils/sandbox-globals.ts @@ -0,0 +1,504 @@ +/** + * 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. + */ + +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"; + + // ---- 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; }; + + 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; + + // ---- 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; + 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.structuredClone = structuredClone; +})(${G}); +`; + +// ── 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; + + 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; + } + }; + + // ---- 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) { + // 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 = ""; + 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; } + }; + }, + 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; } + }; + } + }; + + 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; + + 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 }; + } + + // 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 || {}; + 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 g.Headers(d.headers), + text: function () { return Promise.resolve(d.body); }, + json: function () { return Promise.resolve(JSON.parse(d.body)); } + }; + }); + }; + + // ---- 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)."); }; +})(${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. + */ +export const SANDBOX_PRELUDE = + ENCODING + BINARY + URL_API + HTTP_TYPES + CLONE + CRYPTO + NETWORK + TIMERS + HARNESS;