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 c00e064..a75d287 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,9 +25,16 @@ const modify_request = (ctx, new_req) => { ctx.rq_request_body = new_req; }; const modify_request_using_code = async (action, ctx) => { - // 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))) { + 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") { // User has provided an invalid function return modify_request(ctx, "Can't parse Requestly function. Please recheck. Error Code 944."); } @@ -51,7 +58,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, action.request, args); + finalRequest = await (0, utils_2.executeUserFunction)(ctx, userFunction, args); if (finalRequest && typeof finalRequest === "string") { return modify_request(ctx, finalRequest); } @@ -59,13 +66,8 @@ 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. 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: " + + // Function parsed but failed to execute + return modify_request(ctx, "Can't execute Requestly function. Please recheck. Error Code 187. 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 7c5b37f..2c54af6 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,9 +103,16 @@ const modify_response_using_local = (action, ctx) => { }; const modify_response_using_code = async (action, ctx) => { var _a, _b, _c, _d; - // 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))) { + 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") { // User has provided an invalid function return modify_response(ctx, "Can't parse Requestly function. Please recheck. Error Code 944."); } @@ -139,13 +146,8 @@ 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. 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: " + + // Function parsed but failed to execute + return modify_response(ctx, "Can't execute Requestly function. Please recheck. Error Code 187. Actual Error: " + error.message); } }; diff --git a/dist/utils/index.d.ts b/dist/utils/index.d.ts index 1172b2e..d385ba0 100644 --- a/dist/utils/index.d.ts +++ b/dist/utils/index.d.ts @@ -1,18 +1,2 @@ -/** - * 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 const getFunctionFromString: (functionStringEscaped: any) => any; export declare function executeUserFunction(ctx: any, functionString: string, args: any): Promise; diff --git a/dist/utils/index.js b/dist/utils/index.js index f9a03cf..892f143 100644 --- a/dist/utils/index.js +++ b/dist/utils/index.js @@ -1,452 +1,46 @@ "use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); - __setModuleDefault(result, mod); - return result; -}; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.isValidFunctionString = exports.SandboxError = void 0; +exports.getFunctionFromString = void 0; exports.executeUserFunction = executeUserFunction; -const quickjs_singlefile_cjs_release_sync_1 = __importDefault(require("@jitl/quickjs-singlefile-cjs-release-sync")); -// Import from quickjs-emscripten-core (lean, bring-your-own-variant) rather than -// 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 util_1 = require("util"); +const capture_console_logs_1 = __importDefault(require("capture-console-logs")); const state_1 = __importDefault(require("../components/proxy-middleware/middlewares/state")); -class SandboxError extends Error { - constructor(message, kind) { - super(message); - this.name = "SandboxError"; - this.kind = kind; - } -} -exports.SandboxError = SandboxError; -/** Read a QuickJS error handle out as a host string (best-effort: name + message). */ -function dumpError(vm, handle) { - var _a; - try { - const d = vm.dump(handle); - if (d && typeof d === "object") { - return String((d.name ? d.name + ": " : "") + ((_a = d.message) !== null && _a !== void 0 ? _a : JSON.stringify(d))); - } - return String(d); - } - catch (_b) { - return "unknown sandbox error"; - } -} -/** - * Host-side visibility for sandbox failures (previously these were swallowed). - * `prelude`/`timeout` are OUR problem → always report to Sentry; `user` is the - * rule author's → console only, to avoid telemetry noise. Sentry is wrapped - * because it may be uninitialised in this context (CLI/tests). - */ -function reportSandboxError(kind, message) { - // eslint-disable-next-line no-console - console.error("[rq-sandbox]", kind, message); - if (kind === "user") - return; - try { - Sentry.captureException(new Error("[rq-sandbox:" + kind + "] " + message), { - tags: { sandbox: kind }, - }); - } - catch (_a) { - /* Sentry not initialised — the console.error above is the fallback */ - } -} -const sandbox_globals_1 = require("./sandbox-globals"); -/** - * RQ-2426: rule-supplied "code" rules (Modify Request/Response) used to be run - * 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; - } +// 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}`)(); }; -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. */ +exports.getFunctionFromString = getFunctionFromString; +/* Expects that the functionString has already been validated to be representing a proper function */ async function executeUserFunction(ctx, functionString, args) { - 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(); - } + 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; } diff --git a/dist/utils/sandbox-globals.d.ts b/dist/utils/sandbox-globals.d.ts deleted file mode 100644 index cd47307..0000000 --- a/dist/utils/sandbox-globals.d.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * 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 deleted file mode 100644 index ae0563d..0000000 --- a/dist/utils/sandbox-globals.js +++ /dev/null @@ -1,495 +0,0 @@ -"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 8f7fc78..5c67064 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "1.5.1", "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", @@ -22,7 +21,6 @@ "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", @@ -87,21 +85,6 @@ "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", @@ -3909,15 +3892,6 @@ } ] }, - "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 3aaec03..bad4975 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "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", @@ -40,7 +39,6 @@ "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 0998fca..d152b2c 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, isValidFunctionString } from "../../../../utils"; +import { executeUserFunction, getFunctionFromString } from "../../../../utils"; const process_modify_request_action = (action, ctx) => { const allowed_handlers = [PROXY_HANDLER_TYPE.ON_REQUEST_END]; @@ -31,9 +31,19 @@ const modify_request = (ctx, new_req) => { }; const modify_request_using_code = async (action, ctx) => { - // RQ-2426: validate the function source parses (compile-only, no execution) - // before running it in the sandboxed worker. - if (!(await isValidFunctionString(action.request))) { + 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") { // User has provided an invalid function return modify_request( ctx, @@ -63,21 +73,16 @@ const modify_request_using_code = async (action, ctx) => { /*Do nothing -- could not parse body as JSON */ } - finalRequest = await executeUserFunction(ctx, action.request, args) + finalRequest = await executeUserFunction(ctx, userFunction, 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. 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; + // Function parsed but failed to execute return modify_request( ctx, - "Can't execute Requestly function. Please recheck. Error Code " + - code + - ". Actual Error: " + + "Can't execute Requestly function. Please recheck. Error Code 187. 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 8c22fb5..223a0c4 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, isValidFunctionString } from "../../../../utils"; +import { executeUserFunction, getFunctionFromString } from "../../../../utils"; import { RQ_INTERCEPTED_CONTENT_TYPES_REGEX } from "../../constants"; const process_modify_response_action = async (action, ctx) => { @@ -123,9 +123,19 @@ const modify_response_using_local = (action, ctx) => { }; const modify_response_using_code = async (action, ctx) => { - // RQ-2426: validate the function source parses (compile-only, no execution) - // before running it in the sandboxed worker. - if (!(await isValidFunctionString(action.response))) { + 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") { // User has provided an invalid function return modify_response( ctx, @@ -163,15 +173,10 @@ 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. 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; + // Function parsed but failed to execute return modify_response( ctx, - "Can't execute Requestly function. Please recheck. Error Code " + - code + - ". Actual Error: " + + "Can't execute Requestly function. Please recheck. Error Code 187. Actual Error: " + error.message ); } diff --git a/src/utils/index.ts b/src/utils/index.ts index de6c29c..7b5af9b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,467 +1,54 @@ -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 { types } from "util"; +import ConsoleCapture from "capture-console-logs"; import GlobalStateProvider from "../components/proxy-middleware/middlewares/state"; -/** - * Where a sandbox failure originated, so callers + telemetry can tell OUR - * shim/infra bugs (`prelude`) from the rule author's (`user`) and timeouts apart. - */ -export type SandboxErrorKind = "prelude" | "user" | "timeout"; -export class SandboxError extends Error { - kind: SandboxErrorKind; - constructor(message: string, kind: SandboxErrorKind) { - super(message); - this.name = "SandboxError"; - this.kind = kind; - } -} - -/** Read a QuickJS error handle out as a host string (best-effort: name + message). */ -function dumpError(vm: any, handle: any): string { - try { - const d = vm.dump(handle); - if (d && typeof d === "object") { - return String((d.name ? d.name + ": " : "") + (d.message ?? JSON.stringify(d))); - } - return String(d); - } catch { - return "unknown sandbox error"; - } -} - -/** - * Host-side visibility for sandbox failures (previously these were swallowed). - * `prelude`/`timeout` are OUR problem → always report to Sentry; `user` is the - * rule author's → console only, to avoid telemetry noise. Sentry is wrapped - * because it may be uninitialised in this context (CLI/tests). - */ -function reportSandboxError(kind: SandboxErrorKind, message: string): void { - // eslint-disable-next-line no-console - console.error("[rq-sandbox]", kind, message); - if (kind === "user") return; - try { - Sentry.captureException(new Error("[rq-sandbox:" + kind + "] " + message), { - tags: { sandbox: kind }, - } as any); - } catch { - /* Sentry not initialised — the console.error above is the fallback */ - } -} -import { SANDBOX_PRELUDE } from "./sandbox-globals"; - -/** - * 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: 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; - } +// 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}`)(); }; -// ── 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(); +/* Expects that the functionString has already been validated to be representing a proper function */ +export async function executeUserFunction(ctx, functionString: string, args) { - 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 generateFunctionWithSharedState = function (functionStringEscaped) { - // 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: 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; - - // 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(); - - // 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 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 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); + }; - const outHandle = vm.getProp(vm.global, "__OUTPUT"); - output = vm.dump(outHandle); - outHandle.dispose(); + const {func: generatedFunction, updatedSharedState} = generateFunctionWithSharedState(functionString); + + const consoleCapture = new ConsoleCapture() + consoleCapture.start(true) - 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()))), - ]); - } + let finalResponse = generatedFunction(args); - 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"); + if (types.isPromise(finalResponse)) { + finalResponse = await finalResponse; } - 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"); - } + consoleCapture.stop() + const consoleLogs = consoleCapture.getCaptures() + + ctx.rq.consoleLogs.push(...consoleLogs) - if (parsed.logs?.length && ctx?.rq?.consoleLogs) { - ctx.rq.consoleLogs.push(...parsed.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); - 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] }); + if (typeof finalResponse === "object") { + finalResponse = JSON.stringify(finalResponse); } - reportSandboxError(kind, message); - throw new SandboxError(message, kind); - } - - // Write back any mutations the rule made to $sharedState. - GlobalStateProvider.getInstance().setSharedState(parsed.sharedState ?? {}); - // 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(); - } -} + return finalResponse; +} \ No newline at end of file diff --git a/src/utils/sandbox-globals.ts b/src/utils/sandbox-globals.ts deleted file mode 100644 index 85f8a29..0000000 --- a/src/utils/sandbox-globals.ts +++ /dev/null @@ -1,504 +0,0 @@ -/** - * 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;