Skip to content

fix(security): RQ-2426 — sandbox rule code (QuickJS-WASM)#112

Merged
dinex-dev merged 9 commits into
masterfrom
fix/RQ-2426-sandbox-worker-vm
Jun 26, 2026
Merged

fix(security): RQ-2426 — sandbox rule code (QuickJS-WASM)#112
dinex-dev merged 9 commits into
masterfrom
fix/RQ-2426-sandbox-worker-vm

Conversation

@dinex-dev

@dinex-dev dinex-dev commented Jun 23, 2026

Copy link
Copy Markdown
Member

What

Sandboxes "Code"-type Modify Request/Response rules. They previously ran rule-supplied JS via new Function(...) directly in the proxy's Node process (full require/process/fs/child_process). Code rules travel between users (shared lists, import/export, team sync) → supply-chain RCE.

Rule code now runs inside QuickJS compiled to WebAssembly (quickjs-emscripten-core + the single-file embedded variant).

⚠️ Branch name fix/RQ-2426-sandbox-worker-vm is legacy — force-updated from an earlier worker_threads+vm attempt to the QuickJS approach. Content is QuickJS-WASM.

Why QuickJS-WASM

QuickJS is a separate JS engine in the WASM sandbox — no require/process/fs/global, and no prototype path back to the host (constructor-escape blocked). Only explicitly-injected primitives are reachable. The alternatives don't fit:

  • isolated-vm — native addon; no build for a currently-supported Electron's V8 (6.x too old for V8 13, 7.x needs Node 26).
  • worker_threads + vmworker_threads cannot create a Worker in an Electron renderer, and the proxy runs in the desktop's background renderer.

Security model — the boundary

Only JSON-serialisable data crosses the host edge. No host object is ever handed to the guest, so there is no live reference to walk back to the host realm. Host bridges (crypto, fetch) take copied data in and return copied data out; pure shims never touch the host at all.

Web / Node API compatibility layer

A bare QuickJS realm has none of the web/Node globals scripts used in the old full-host environment. This restores them so existing rule scripts keep working, without weakening the boundary.

Pure in-guest JS shims (no host contact):
URL, URLSearchParams, TextEncoder/TextDecoder, structuredClone, atob/btoa, Buffer, Blob, FormData, Request, Response, Headers, setTimeout/setInterval/clearTimeout/clearInterval, queueMicrotask, performance, console (captured into consoleLogs).

Host bridges (copy-in / copy-out only):

  • cryptorandomUUID, getRandomValues, createHash, createHmac, subtle.digest
  • fetch — real HTTP via the host, driven by a guest-promise + pump-loop (works on the sync QuickJS variant; avoids the asyncify teardown race)
  • XMLHttpRequest (async) — layered on the fetch bridge
  • require('crypto') → the crypto bridge; any other require(...) throws a guided error

fetch policy: http/https URLs only; credentials: 'omit' (a shared rule can't ride the user's ambient cookies/sessions).

Engine note: imports from quickjs-emscripten-core (not the umbrella quickjs-emscripten, whose auto-loader statically references every WASM variant and breaks the desktop's webpack bundle). core + the single embedded variant is bundler-safe — same choice as @requestly/sandbox-node.

Limitations (by design)

API Limitation
WebSocket constructor throws — a persistent connection can't outlive a per-request execution
sync XMLHttpRequest (open(...,false)) throws — QuickJS can't block; use async / fetch
crypto.subtle only digest; sign/verify/importKey/generateKey deferred (CryptoKey can't cross the copy boundary). HMAC signing via require('crypto').createHmac
setInterval no-op (returns id) — a repeating timer can't outlive a per-request execution. setTimeout does honor the real delay (clamped to the execution's wall-clock budget)
fetch bodies FormData/Blob multipart parts cross as text (binary best-effort)
fetch egress no SSRF / private-IP hardening yet (tracked separately)

Code layout

  • src/utils/index.tshost side: module/context lifecycle, the __hostCrypto/__hostFetch bridge handlers, the pump loop, executeUserFunction (per-step CPU interrupt + 15s wall-clock cap incl. async I/O, 128 MB mem cap). isValidFunctionString compiles via new Function without calling it (parse-only; no vm).
  • src/utils/sandbox-globals.tsguest side: all the in-guest source (SANDBOX_POLYFILLS, SANDBOX_BRIDGE_SHIMS, SANDBOX_EXTRA_SHIMS, SANDBOX_SETUP).
  • Both processors validate → pass the source string. Contract preserved: returns a string (objects JSON-stringified), promises awaited, console captured, $sharedState read + written back (snapshot read after the last await so the read-modify-write is atomic).

Error handling & observability

Sandbox failures are categorised and surfaced (previously they were swallowed — a regressed shim degraded to a generic 187 with no signal).

  • The prelude (our shims) is eval'd separately from the user wrapper, so a shim error is unambiguously ours, and is dumped (not disposed).
  • The user fn runs inside a .then, so synchronous throws are captured like async ones (no longer leak out as untracked eval errors).
  • executeUserFunction throws a typed SandboxError { kind: "prelude" | "user" | "timeout" } carrying the real message; success still returns the result. A CPU-deadline interrupt is classified timeout.
  • reportSandboxError(): console.error("[rq-sandbox]", kind, msg) (host-visible) for all kinds; prelude/timeout also Sentry.captureException tagged sandbox: <kind> (guarded — Sentry may be uninitialised). user → console only.
  • Processors surface the real message with a distinct code: 187 = rule author's code, 188 = sandbox-internal (our bug).

Verified

  • Headless harness on Node 24: core sandbox + all bridges + all shims + escape-blocked + fetch policy.
  • Live in the packaged Electron 42 desktop app via a full Modify-Response Code rule through real proxied traffic: fetch/XHR/crypto(hash/hmac/subtle.digest)/Buffer/Blob/FormData/Request/Response/timers/URL/encoding all pass; file:///ftp:// blocked; WebSocket throws; $sharedState persists across requests; process/require('fs')/constructor-escape all unreachable.

Downstream / ship order

Desktop needs only a proxy dep bump (folded into #358) — no desktop code change. Ships with the Node-24 writeHead fix (#113), which is required for interception on Electron 42 (Node 24). Merge #112 + #113 → publish proxy → bump dep in #358.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • User-provided “code” actions now run inside a QuickJS-based sandbox with enforced time/memory limits and safer host-bridge APIs (crypto, fetch, timers).
    • Added pre-execution validation of the provided function source to prevent malformed code from running.
  • Bug Fixes

    • Improved request/response code-path error handling: more accurate failure codes based on error type and clearer error messages, with logs included in results.
  • Chores

    • Updated dependencies to support the QuickJS single-file sandbox runtime and core engine.

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0cd99410-3f6f-42da-bcfb-85ff1ed979ef

📥 Commits

Reviewing files that changed from the base of the PR and between 38cee65 and 2200e76.

⛔ Files ignored due to path filters (1)
  • dist/utils/sandbox-globals.js is excluded by !**/dist/**
📒 Files selected for processing (1)
  • src/utils/sandbox-globals.ts

Walkthrough

The PR adds QuickJS runtime dependencies, introduces sandbox globals for guest execution, rewrites executeUserFunction to run rule code inside QuickJS with resource limits and host bridges, and updates request and response processors to validate rule source strings before execution.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main security change: sandboxing rule code with QuickJS-WASM.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/RQ-2426-sandbox-worker-vm

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jun 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@requestly/requestly-proxy@112

commit: 2200e76

"Code"-type Modify Request/Response rules ran rule-supplied JS via
`new Function(...)` directly in the proxy's Node process (full require/process/
fs/child_process). Code rules travel between users (shared lists, import/export,
team sync), so this was a supply-chain RCE primitive.

Rule code now runs inside QuickJS compiled to WebAssembly (quickjs-emscripten,
single-file embedded variant). QuickJS is a separate JS engine in the WASM
sandbox: no require/process/fs/global, and no prototype path back to the host
(constructor-escape blocked). Only injected primitives are reachable.

Why QuickJS-WASM (not isolated-vm or worker_threads + vm):
- isolated-vm is a native addon with no build for a supported Electron's V8
  (6.x too old for V8 13, 7.x needs Node 26).
- worker_threads cannot create a Worker in an Electron renderer ("The V8
  platform ... does not support creating Workers"), and the proxy runs in the
  desktop app's background renderer.
QuickJS-WASM is pure WASM+JS — builds nowhere natively and runs in the renderer.

- src/utils/index.ts: executeUserFunction runs in QuickJS; 5s deadline interrupt,
  128MB cap. isValidFunctionString compiles via `new Function` WITHOUT calling it
  (parse-only, no execution). getFunctionFromString removed.
- both Modify Request/Response processors: validate -> pass the source string.
- contract preserved: returns a string (objects JSON-stringified), promises
  awaited, console captured as {type,args}, $sharedState read + written back.
- intentional gap: no fetch/Buffer/timers (fetch needs the asyncify variant +
  async host bridge — a follow-up; QuickJS can do it safely).

Verified: sandbox harness 13/13 (Node 24); instantiates + runs + blocks
host access inside the Electron 42 renderer; before/after exploit probe flips
from RCE/file/env/process access to fully blocked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dinex-dev dinex-dev force-pushed the fix/RQ-2426-sandbox-worker-vm branch from 77c31fd to 2f32940 Compare June 23, 2026 11:23
@dinex-dev dinex-dev changed the title fix(security): RQ-2426 — sandbox rule code (worker_threads + vm) fix(security): RQ-2426 — sandbox rule code (QuickJS-WASM) Jun 23, 2026
@dinex-dev dinex-dev marked this pull request as ready for review June 23, 2026 13:16

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@package.json`:
- Line 3: The version field in package.json has been downgraded from 1.5.0 to
1.4.0, which violates semantic versioning and will cause issues with release
management tools. Update the version value to a forward version number that is
higher than the previously published 1.5.0 (such as 1.5.1 or 1.6.0, depending on
whether this is a patch or minor release) to maintain proper version ordering
for releases.

In `@src/utils/index.ts`:
- Around line 167-168: The call to executePendingJobs() on the vm.runtime object
returns a result containing a QuickJSHandle that must be properly disposed to
prevent VM memory leaks. Capture the return value from executePendingJobs() and
check if it contains an error handle, then ensure the handle is disposed by
calling the appropriate disposal method on the returned result before
proceeding. This will prevent memory leaks when deadline interrupts or job
errors occur.
- Around line 114-120: The issue is a race condition where the snapshot read via
GlobalStateProvider.getInstance().getSharedStateCopy() occurs before an await
statement, allowing concurrent invocations to modify shared state via
setSharedState() while control is yielded, causing stale state to overwrite
concurrent updates when execution resumes. Move the entire try-catch block
containing the getSharedStateCopy() call to execute after the await
getQuickJSModule() statement completes, so that the snapshot is captured after
the async module resolution rather than before it. This closes the interleaving
window and ensures the snapshot reflects current state at the point it will be
used.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a2509111-22a8-4f66-8744-b17a2a20643b

📥 Commits

Reviewing files that changed from the base of the PR and between 381f6b9 and 2f32940.

⛔ Files ignored due to path filters (5)
  • dist/components/proxy-middleware/rule_action_processor/processors/modify_request_processor.js is excluded by !**/dist/**
  • dist/components/proxy-middleware/rule_action_processor/processors/modify_response_processor.js is excluded by !**/dist/**
  • dist/utils/index.d.ts is excluded by !**/dist/**
  • dist/utils/index.js is excluded by !**/dist/**
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (4)
  • package.json
  • src/components/proxy-middleware/rule_action_processor/processors/modify_request_processor.js
  • src/components/proxy-middleware/rule_action_processor/processors/modify_response_processor.js
  • src/utils/index.ts

Comment thread package.json Outdated
Comment thread src/utils/index.ts
Comment thread src/utils/index.ts Outdated
dinex-dev and others added 4 commits June 24, 2026 11:44
QuickJS is a bare ECMAScript engine — it lacks the Web/Node globals rule code
commonly expects. Add them as PURE-JS shims that run inside the sandbox, built
only from QuickJS built-ins so no host object crosses the boundary (same safety
model as atob/btoa; verified require/process stay undefined in-sandbox):

- URL / URLSearchParams (regex-based parser; common cases — protocol/host/port/
  path/search/hash/origin, searchParams, basic relative-base resolution)
- TextEncoder / TextDecoder (UTF-8)
- structuredClone (deep clone, preserves Date/Map/Set, handles cycles)
- crypto.randomUUID / crypto.getRandomValues

NOTE on crypto: this is a Math.random-based STOPGAP — NOT cryptographically
secure (no entropy source in the WASM realm). Fine for ids/non-security random;
secure crypto should be a host bridge (follow-up), matching
@requestly/sandbox-node's byte-identical-to-host crypto approach.

Aligns with the API client's QuickJS sandbox model: pure-JS shims for
computation-only APIs, host bridge reserved for capability APIs (crypto/fetch).
URL is hand-rolled (sandbox-node doesn't expose URL — uses a regex internally —
so this is a superset of theirs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The QuickJS sandbox commit was assembled from a checkpoint based on an older
master and inadvertently reverted package.json/package-lock from 1.5.0 to 1.4.0.
Restore to 1.5.0 so the branch's only package.json delta vs master is the added
quickjs-emscripten deps. Dependency versions left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Read the $sharedState snapshot after the last await so the snapshot->eval->
  setSharedState read-modify-write is atomic w.r.t. the event loop. Reading it
  before the await let a concurrent executeUserFunction commit in the gap, whose
  update this call's stale snapshot would then clobber (last-writer-wins).
- Capture executePendingJobs() result and dispose its error handle (carried on
  a job error / deadline interrupt) instead of discarding it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rule "Code" runs in the QuickJS-WASM sandbox (a bare ES realm), so any web/Node
global a script used in the old full-host environment is otherwise missing. This
adds a compatibility layer so existing scripts keep working, without weakening the
isolation boundary — only JSON-serialisable data crosses the host edge.

Capabilities, by mechanism:
- Pure in-guest JS shims (no host contact): URL, URLSearchParams, TextEncoder/
  Decoder, structuredClone, atob/btoa, Buffer, Blob, FormData, Request, Response,
  Headers, setTimeout/setInterval/clearTimeout/clearInterval, queueMicrotask,
  performance.
- Host bridges (copy-in/copy-out only, no host object exposed):
  - crypto: randomUUID, getRandomValues, createHash, createHmac, subtle.digest
  - fetch: real HTTP via host fetch, driven by a guest-promise + pump loop.
- XMLHttpRequest (async) layered on the fetch bridge.
- require('crypto') maps to the crypto bridge; any other require(...) throws a
  guided error (fs/process/etc. stay absent by design).

fetch policy: http(s) URLs only; credentials: 'omit' (a shared rule cannot ride
the user's ambient cookies/sessions).

Engine: import from quickjs-emscripten-core (not the umbrella quickjs-emscripten),
whose auto-loader statically references every WASM variant and breaks bundlers
(the desktop's webpack). core + the single embedded variant is bundler-safe.

Refactor: the in-guest source strings moved to utils/sandbox-globals.ts
(guest realm) so utils/index.ts is host orchestration only.

Deliberate limitations:
- WebSocket: constructor throws — a persistent connection can't outlive a
  per-request execution.
- Synchronous XMLHttpRequest (open(..., false)): throws — QuickJS can't block;
  use async / fetch.
- crypto.subtle: only digest implemented; sign/verify/importKey/generateKey
  deferred (CryptoKey objects can't cross the copy boundary). HMAC signing is
  available via require('crypto').createHmac.
- setTimeout fires once via a microtask (delay not honoured); setInterval is a
  no-op (returns an id) so a per-request rule cannot spin forever.
- fetch: FormData/Blob multipart parts cross as text (binary parts best-effort).
  No SSRF/private-IP hardening yet (tracked separately).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/utils/sandbox-globals.ts (1)

224-234: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Preserve byte semantics in the crypto compatibility layer.

randomBytes returns a plain array, so common code like require("crypto").randomBytes(16).toString("hex") won’t behave like Node. createHash/createHmac also stringify byte inputs and keys, which can produce incorrect digests/signatures.

Also applies to: 427-428

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/sandbox-globals.ts` around lines 224 - 234, The crypto
compatibility layer is treating byte data like plain strings/arrays, so
`randomBytes`, `createHash`, and the related `createHmac` path need to preserve
Node-like byte semantics. Update the implementations in `sandbox-globals` so
`randomBytes` returns a byte-like value that supports `toString("hex")`, and
ensure `createHash`/`createHmac` pass raw byte input and keys through without
forcing `String(...)` coercion before calling `__hostCrypto`.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/utils/sandbox-globals.ts`:
- Around line 329-335: The timer shim in sandbox-globals is currently masking
real timing behavior by making setTimeout fire immediately and ignoring ms,
which can break retry/backoff logic; update the timer handling around
g.setTimeout/g.clearTimeout/g.setInterval/g.clearInterval to either throw for
unsupported timers or wire them to real delay-aware host scheduling instead of
microtask-based execution.
- Around line 255-264: `hostFetchOp` currently allows only http/https and
timeouts, but still permits SSRF to private, loopback, and metadata IPs. Update
the request flow in `hostFetchOp` (before the host `fetch` call) to resolve
`parsedUrl.hostname`, detect any resolved IPs in private/link-local/loopback
ranges, and reject them with an error. Keep the existing protocol allow-list and
size/timeout handling intact, and make sure the check runs for every URL before
issuing the fetch.

---

Nitpick comments:
In `@src/utils/sandbox-globals.ts`:
- Around line 224-234: The crypto compatibility layer is treating byte data like
plain strings/arrays, so `randomBytes`, `createHash`, and the related
`createHmac` path need to preserve Node-like byte semantics. Update the
implementations in `sandbox-globals` so `randomBytes` returns a byte-like value
that supports `toString("hex")`, and ensure `createHash`/`createHmac` pass raw
byte input and keys through without forcing `String(...)` coercion before
calling `__hostCrypto`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5eaadfe4-2c23-4109-9433-6e6d03342ac8

📥 Commits

Reviewing files that changed from the base of the PR and between 2f32940 and f9bd049.

⛔ Files ignored due to path filters (4)
  • dist/utils/index.js is excluded by !**/dist/**
  • dist/utils/sandbox-globals.d.ts is excluded by !**/dist/**
  • dist/utils/sandbox-globals.js is excluded by !**/dist/**
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (3)
  • package.json
  • src/utils/index.ts
  • src/utils/sandbox-globals.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/utils/index.ts

Comment thread src/utils/sandbox-globals.ts Outdated
Comment on lines +255 to +264
g.fetch = function (url, init) {
init = init || {};
var hdrs = init.headers || {};
if (hdrs && typeof hdrs.forEach === "function" && hdrs.__h) { var o = {}; hdrs.forEach(function (v, k) { o[k] = v; }); hdrs = o; }
var req = JSON.stringify({
url: String(url),
method: init.method || "GET",
headers: hdrs,
body: init.body != null ? String(init.body) : undefined
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the host fetch bridge for URL allow/deny policy, timeout, and response-size limits.
rg -n -C8 '\bhostFetchOp\b|__hostFetch|fetch\(' src

Repository: requestly/requestly-proxy

Length of output: 11589


Strengthen hostFetchOp by blocking private network and metadata endpoints.

The hostFetchOp function in src/utils/index.ts (lines 143–188) correctly enforces protocol allow-listing (http/https only) and applies FETCH_TIMEOUT_MS (10s) and MAX_FETCH_BODY_BYTES (25MB) limits. However, it lacks explicit SSRF protections. The function currently relies on the underlying host environment to resolve hostnames, leaving it vulnerable to private network or cloud metadata endpoint access (e.g., 169.254.169.254, 127.0.0.1). Add logic to resolve parsedUrl.hostname and reject any resulting IP addresses falling within private, loopback, or link-local ranges before invoking the host fetch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/sandbox-globals.ts` around lines 255 - 264, `hostFetchOp` currently
allows only http/https and timeouts, but still permits SSRF to private,
loopback, and metadata IPs. Update the request flow in `hostFetchOp` (before the
host `fetch` call) to resolve `parsedUrl.hostname`, detect any resolved IPs in
private/link-local/loopback ranges, and reject them with an error. Keep the
existing protocol allow-list and size/timeout handling intact, and make sure the
check runs for every URL before issuing the fetch.

Comment thread src/utils/sandbox-globals.ts Outdated
dinex-dev and others added 2 commits June 25, 2026 11:46
setTimeout previously fired once on a microtask and ignored `ms`, silently turning
retry/backoff code into a hot loop (hammering endpoints, ignoring rate limits) and
breaking delay-based ordering — a quiet behavioral surprise for existing rules.

Add a __hostTimer bridge: the guest setTimeout now waits the real delay via a host
timer, clamped host-side to the execution's remaining wall-clock budget (so a timer
can never outlast the run), driven by the existing guest-promise + pump loop.
clearTimeout cancels the pending callback. setInterval stays a no-op (a repeating
timer can't outlive a per-request execution); queueMicrotask stays a microtask.

Chose delay-aware scheduling over throwing on setTimeout: throwing would break the
common benign `setTimeout(fn, 0)` yield and library-internal timer use.

Verified: 120ms timer elapses ~123ms; delay ordering (fast before slow); backoff
loop accumulates real delay; clearTimeout cancels; setTimeout(0) still yields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Regroup the in-guest source from "when it was added" (POLYFILLS/BRIDGE_SHIMS/
EXTRA_SHIMS) to "what it is", as dependency-ordered, self-describing blocks:
ENCODING, BINARY, URL, HTTP_TYPES, CLONE, CRYPTO, NETWORK, TIMERS, HARNESS —
composed into a single exported SANDBOX_PRELUDE (index.ts now imports one constant
instead of four).

- Factor the byte helpers (utf8/hex/base64) into one shared __rqb namespace
  instead of being trapped in the EXTRA block.
- Merge the awkward fetch re-wrap (base fetch + a second wrapper via __origFetch)
  into a single body-aware fetch.
- Round out Headers (set/append/delete).

No behavior change — full capability suite (fetch + policy, crypto incl.
hmac/subtle.digest, Buffer, URL, encoding, structuredClone, Blob/FormData/
Response, multipart, XHR, real-delay setTimeout, WebSocket guard, isolation,
sharedState) passes identically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previously any sandbox failure was swallowed: a prelude (our shim) error was
disposed silently, user throws/timeouts degraded to a generic "Returned value is
not a string", nothing reached host logs/Sentry, and synchronous user throws
leaked out as untracked top-level eval errors. A regressed polyfill would have
been invisible in production.

Now executeUserFunction categorises and surfaces every failure:
- Eval the prelude (our shims) SEPARATELY from the user wrapper, so a shim error
  is unambiguously ours (kind "prelude") and is dumped, not disposed.
- Run the user fn inside a `.then` so SYNC throws become rejections captured by
  `.catch` (→ __OUTPUT.error) like async ones, instead of leaking out.
- Throw a typed SandboxError { kind: "prelude" | "user" | "timeout" } carrying the
  real message; success still returns the result string. A CPU-deadline interrupt
  is classified as "timeout" (not a user logic error).
- reportSandboxError(): always console.error("[rq-sandbox]", kind, msg); prelude/
  timeout also go to Sentry (guarded — may be uninitialised). user → console only.

Processors now show the real error and a distinct code: 188 = sandbox-internal
(our bug), 187 = the rule author's code.

Verified: prelude error reported (not swallowed); user sync + async throws surface
the real message; infinite loop → timeout; happy paths for all APIs unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/utils/index.ts (1)

277-345: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Cap in-flight host bridge operations.

A rule can enqueue many fetch() or setTimeout() calls before the CPU interrupt fires; those host promises/timers/network requests live outside the QuickJS memory limit. Add a small MAX_INFLIGHT_HOST_OPS guard and fail further bridge calls once reached.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/index.ts` around lines 277 - 345, The host bridge functions can
enqueue too many pending async operations before the CPU interrupt stops
execution, so add a small MAX_INFLIGHT_HOST_OPS limit to cap outstanding work.
Update the __hostFetch and __hostTimer paths in the bridge setup to check the
current inflight array size before pushing new promises, and return a
failure/throw a host error once the limit is reached. Keep the guard centralized
near the inflight tracking in src/utils/index.ts so both fetch and timer calls
are covered consistently.
♻️ Duplicate comments (1)
src/utils/index.ts (1)

250-254: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Protect $sharedState across async host waits.

The snapshot is still taken before an await: user code can hit fetch/setTimeout, the pump loop yields at Line 416, and another rule can commit shared state before Line 459 overwrites it with this stale snapshot. Use an async mutex/CAS-style update around the full read→execute→write path, or merge against the latest state before setSharedState.

Also applies to: 416-419, 458-459

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/index.ts` around lines 250 - 254, Protect the shared state update
in the flow around getSharedStateCopy, the async host wait, and setSharedState
by making the read→execute→write sequence atomic. Use an async mutex or
compare-and-swap style guard in the logic that snapshots shared state, yields
during the host wait, and later writes back, so a stale sharedStateJson cannot
overwrite newer changes from another rule. If full atomicity is not possible,
refresh or merge against the latest state immediately before setSharedState in
the same execution path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/components/proxy-middleware/rule_action_processor/processors/modify_request_processor.js`:
- Around line 71-83: The failure path in modify_request_processor’s catch block
is leaking internal sandbox details because modify_request writes the returned
string into ctx.rq_request_body and forwards it upstream. Update the error
handling around modify_request so execution failures do not replace the outgoing
request body with the raw sandbox message; instead, preserve the original body
or route the diagnostic to a developer-facing channel, and guard access to
error.message for non-Error throws. Use modify_request_processor and
modify_request as the key symbols when locating the fix.

In `@src/utils/sandbox-globals.ts`:
- Around line 371-375: The sandbox crypto shim’s randomBytes() currently returns
a plain array, so update the nodeCrypto.randomBytes implementation in
sandbox-globals to wrap the host-provided bytes with the sandbox Buffer before
returning. Keep the existing host call and JSON parsing, but convert the parsed
bytes into a Buffer-compatible value so callers of
require("crypto").randomBytes() can use standard Node Buffer methods like
toString("hex").

---

Outside diff comments:
In `@src/utils/index.ts`:
- Around line 277-345: The host bridge functions can enqueue too many pending
async operations before the CPU interrupt stops execution, so add a small
MAX_INFLIGHT_HOST_OPS limit to cap outstanding work. Update the __hostFetch and
__hostTimer paths in the bridge setup to check the current inflight array size
before pushing new promises, and return a failure/throw a host error once the
limit is reached. Keep the guard centralized near the inflight tracking in
src/utils/index.ts so both fetch and timer calls are covered consistently.

---

Duplicate comments:
In `@src/utils/index.ts`:
- Around line 250-254: Protect the shared state update in the flow around
getSharedStateCopy, the async host wait, and setSharedState by making the
read→execute→write sequence atomic. Use an async mutex or compare-and-swap style
guard in the logic that snapshots shared state, yields during the host wait, and
later writes back, so a stale sharedStateJson cannot overwrite newer changes
from another rule. If full atomicity is not possible, refresh or merge against
the latest state immediately before setSharedState in the same execution path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cea1e224-151e-4c17-b992-4681b16aa39f

📥 Commits

Reviewing files that changed from the base of the PR and between f9bd049 and 38cee65.

⛔ Files ignored due to path filters (6)
  • dist/components/proxy-middleware/rule_action_processor/processors/modify_request_processor.js is excluded by !**/dist/**
  • dist/components/proxy-middleware/rule_action_processor/processors/modify_response_processor.js is excluded by !**/dist/**
  • dist/utils/index.d.ts is excluded by !**/dist/**
  • dist/utils/index.js is excluded by !**/dist/**
  • dist/utils/sandbox-globals.d.ts is excluded by !**/dist/**
  • dist/utils/sandbox-globals.js is excluded by !**/dist/**
📒 Files selected for processing (4)
  • src/components/proxy-middleware/rule_action_processor/processors/modify_request_processor.js
  • src/components/proxy-middleware/rule_action_processor/processors/modify_response_processor.js
  • src/utils/index.ts
  • src/utils/sandbox-globals.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/proxy-middleware/rule_action_processor/processors/modify_response_processor.js

Comment on lines 71 to 83
} catch (error) {
// Function parsed but failed to execute
// Function parsed but failed to execute. Code 188 = sandbox-internal (our shim
// broke); 187 = the rule author's code. error.message now carries the real
// sandbox error (previously swallowed).
const code = error && error.kind === "prelude" ? 188 : 187;
return modify_request(
ctx,
"Can't execute Requestly function. Please recheck. Error Code 187. Actual Error: " +
"Can't execute Requestly function. Please recheck. Error Code " +
code +
". Actual Error: " +
error.message
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

On execution failure the request body is overwritten with the raw sandbox error and forwarded upstream.

modify_request assigns ctx.rq_request_body = new_req, so this catch replaces the outgoing request body with the error string — now including the raw error.message. Unlike modify_response_processor.js, where the message is surfaced back to the developer, here it is sent to the origin server, leaking internal sandbox detail. Also, for non-Error throws error.message will render as undefined.

Consider guarding the message and reconsidering whether the upstream request body should carry sandbox internals.

Verify how rq_request_body is consumed after this processor runs (i.e., whether it is forwarded to the origin):

#!/bin/bash
rg -nP -C3 '\brq_request_body\b' --type=js
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/components/proxy-middleware/rule_action_processor/processors/modify_request_processor.js`
around lines 71 - 83, The failure path in modify_request_processor’s catch block
is leaking internal sandbox details because modify_request writes the returned
string into ctx.rq_request_body and forwards it upstream. Update the error
handling around modify_request so execution failures do not replace the outgoing
request body with the raw sandbox message; instead, preserve the original body
or route the diagnostic to a developer-facing channel, and guard access to
error.message for non-Error throws. Use modify_request_processor and
modify_request as the key symbols when locating the fix.

Comment thread src/utils/sandbox-globals.ts
The host bridge returns the random bytes as a plain JSON array (only data crosses
the sandbox boundary), so randomBytes(n) handed the guest a plain Array and
randomBytes(16).toString('hex') produced comma-joined decimals instead of hex.
Wrap the crossed-over bytes in the guest Buffer shim so the return type matches
Node (toString('hex'/'base64'/'utf8') work). Entropy is unchanged (real host
node:crypto); only the guest-side container type is restored.

Addresses CodeRabbit review on PR #112.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dinex-dev dinex-dev merged commit 67de108 into master Jun 26, 2026
8 checks passed
@dinex-dev dinex-dev deleted the fix/RQ-2426-sandbox-worker-vm branch June 26, 2026 11:27
@github-actions github-actions Bot mentioned this pull request Jun 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants