sec(templating): QuickJS template-tag sandbox additions #10209
Merged
jackkav merged 7 commits intoJul 6, 2026
Conversation
QuickJS's executePendingJobs() blocks the host thread until a synchronous call returns, so the wall-clock deadline in drivePromiseToString was never checked during a tight sync loop in plugin code, hanging the Electron main process indefinitely. Add a QuickJS interrupt handler, which is polled during synchronous execution, to enforce the deadline. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
hostCrypto.randomBytes(size) passed the sandboxed number straight to Node's crypto.randomBytes with no upper bound, letting a plugin request a multi-GB allocation (e.g. crypto.randomBytes(2 ** 31)) and crash the host process. Clamp to 64KB before the call. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
QuickJS.newContext() had no memory limit, so a plugin allocating without bound could exhaust the WASM heap and crash the host process. Set a 32MB ceiling via ctx.runtime.setMemoryLimit(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getPluginEntrySource's containment check compared raw path strings, so a plugin directory with a symlinked entry (e.g. index.js -> ../../../etc/secret) passed the check while fs.readFileSync followed the symlink and read the out-of-directory target. Re-run the check against fs.realpathSync'd paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
context.util.render() bridged to the shared render() pipeline, whose Liquid
engine dispatches any registered tag's real run() directly, in-process,
regardless of templateTagSandboxEnabled. A sandboxed plugin could hand it a
string containing "{% anyTag %}" (including its own tag) and have that tag
execute completely unsandboxed. Verified with a PoC that reached
child_process execution from inside a plugin tag with no require() or Node
access.
util.render is now restricted to plain {{ variable }} interpolation (the
only real existing use, confirmed against all built-in tag call sites) via
a second Liquid engine with no tags registered; {% tag %} syntax now fails
to parse instead of dispatching. Default render() behavior is unchanged for
every other caller.
Also drops the dead renderDepth field's misleading doc comment: within one
sandboxed execution the envelope's renderDepth is always 0, so depth could
never exceed 1 regardless of enforcement — it couldn't have caught this
recursion anyway.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
✅ Circular References ReportGenerated at: 2026-07-06T18:38:35.879Z Summary
Click to view all circular references in PR (9)Click to view all circular references in base branch (9)Analysis✅ No Change: This PR does not introduce or remove any circular references. This report was generated automatically by comparing against the |
jackkav
pushed a commit
that referenced
this pull request
Jul 6, 2026
* fix(sandbox): enforce timeout on synchronous plugin loops
QuickJS's executePendingJobs() blocks the host thread until a synchronous
call returns, so the wall-clock deadline in drivePromiseToString was never
checked during a tight sync loop in plugin code, hanging the Electron main
process indefinitely. Add a QuickJS interrupt handler, which is polled
during synchronous execution, to enforce the deadline.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sandbox): clamp crypto.randomBytes size to prevent OOM
hostCrypto.randomBytes(size) passed the sandboxed number straight to
Node's crypto.randomBytes with no upper bound, letting a plugin request
a multi-GB allocation (e.g. crypto.randomBytes(2 ** 31)) and crash the
host process. Clamp to 64KB before the call.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sandbox): cap QuickJS heap to prevent unbounded allocation
QuickJS.newContext() had no memory limit, so a plugin allocating without
bound could exhaust the WASM heap and crash the host process. Set a 32MB
ceiling via ctx.runtime.setMemoryLimit().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sandbox): resolve symlinks before validating plugin entry path
getPluginEntrySource's containment check compared raw path strings, so a
plugin directory with a symlinked entry (e.g. index.js -> ../../../etc/secret)
passed the check while fs.readFileSync followed the symlink and read the
out-of-directory target. Re-run the check against fs.realpathSync'd paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sandbox): close util.render sandbox escape
context.util.render() bridged to the shared render() pipeline, whose Liquid
engine dispatches any registered tag's real run() directly, in-process,
regardless of templateTagSandboxEnabled. A sandboxed plugin could hand it a
string containing "{% anyTag %}" (including its own tag) and have that tag
execute completely unsandboxed. Verified with a PoC that reached
child_process execution from inside a plugin tag with no require() or Node
access.
util.render is now restricted to plain {{ variable }} interpolation (the
only real existing use, confirmed against all built-in tag call sites) via
a second Liquid engine with no tags registered; {% tag %} syntax now fails
to parse instead of dispatching. Default render() behavior is unchanged for
every other caller.
Also drops the dead renderDepth field's misleading doc comment: within one
sandboxed execution the envelope's renderDepth is always 0, so depth could
never exceed 1 regardless of enforcement — it couldn't have caught this
recursion anyway.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sandbox): fixed linting issue
* fix(sandbox): fixed linting issue
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
jackkav
added a commit
that referenced
this pull request
Jul 6, 2026
…e canary (#10207) * feat(templating): PoC run plugin template tags in a QuickJS-WASM sandbox Behind a new `templateTagSandboxEnabled` setting (default off), route plugin template-tag execution through a QuickJS-WASM sandbox instead of invoking the plugin's `run()` directly in the main process. Approach (per PR #10072): bulk-copy render state into the sandbox as JSON, rebuild the plugin `context` API in pure JS inside the sandbox, and bridge only async work back to the host via the existing `pluginToMainAPI` handlers. `node:crypto` is exposed as synchronous host functions so `require('crypto')` works without a sync/async mismatch. - templating/sandbox/: quickjs-runtime, marshal, host-bridge, in-sandbox-bootstrap, plugin-tag-sandbox (+ parity tests vs in-process tags and node:crypto) - main/templating-worker-database.ts: route execute handlers through the sandbox when the flag is on; legacy path unchanged otherwise - esbuild: keep quickjs-emscripten external so its .wasm resolves at runtime - settings + scripting-settings UI toggle - examples/insomnia-plugin-sandbox-demo: manual E2E fixture Scope: template tags only; sandbox runs in main. require shim covers path + crypto (other modules throw a clear error — follow-up work). * test(smoke): e2e canary for the template-tag sandbox flag Installs an inline probe plugin, renders its tags via the tag editor Live Preview, and asserts the execution path flips main-process -> sandbox when templateTagSandboxEnabled is toggled in Preferences > Scripting, with a require('crypto') sha256 workload staying byte-identical across both paths. * test(sandbox): suppress hardcoded-hmac-key semgrep finding on parity fixture The HMAC key is a test vector for sandbox-vs-node:crypto parity, not a credential; rename it to make that self-evident and add the repo-standard nosemgrep suppression. * fix(templating): contain sandbox plugin entry resolution to the plugin directory Reject a package.json "main" that resolves outside the plugin's own folder and bundled-plugin names that look like paths, so the sandbox source loader cannot be steered into reading arbitrary files. * fix(review): inline nosemgrep placement, plugin-load error context, cross-arch-safe canary - Move the hardcoded-hmac-key suppression onto the flagged line (line-above placement was not honored by the scanner). - Wrap getPluginEntrySource failures with the plugin name for diagnosability. - Derive the canary's expected arch from the Electron main process instead of the Playwright runner so cross-arch setups can't flake the assertion. * sec(templating): QuickJS template-tag sandbox additions (#10209) * fix(sandbox): enforce timeout on synchronous plugin loops QuickJS's executePendingJobs() blocks the host thread until a synchronous call returns, so the wall-clock deadline in drivePromiseToString was never checked during a tight sync loop in plugin code, hanging the Electron main process indefinitely. Add a QuickJS interrupt handler, which is polled during synchronous execution, to enforce the deadline. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sandbox): clamp crypto.randomBytes size to prevent OOM hostCrypto.randomBytes(size) passed the sandboxed number straight to Node's crypto.randomBytes with no upper bound, letting a plugin request a multi-GB allocation (e.g. crypto.randomBytes(2 ** 31)) and crash the host process. Clamp to 64KB before the call. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sandbox): cap QuickJS heap to prevent unbounded allocation QuickJS.newContext() had no memory limit, so a plugin allocating without bound could exhaust the WASM heap and crash the host process. Set a 32MB ceiling via ctx.runtime.setMemoryLimit(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sandbox): resolve symlinks before validating plugin entry path getPluginEntrySource's containment check compared raw path strings, so a plugin directory with a symlinked entry (e.g. index.js -> ../../../etc/secret) passed the check while fs.readFileSync followed the symlink and read the out-of-directory target. Re-run the check against fs.realpathSync'd paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sandbox): close util.render sandbox escape context.util.render() bridged to the shared render() pipeline, whose Liquid engine dispatches any registered tag's real run() directly, in-process, regardless of templateTagSandboxEnabled. A sandboxed plugin could hand it a string containing "{% anyTag %}" (including its own tag) and have that tag execute completely unsandboxed. Verified with a PoC that reached child_process execution from inside a plugin tag with no require() or Node access. util.render is now restricted to plain {{ variable }} interpolation (the only real existing use, confirmed against all built-in tag call sites) via a second Liquid engine with no tags registered; {% tag %} syntax now fails to parse instead of dispatching. Default render() behavior is unchanged for every other caller. Also drops the dead renderDepth field's misleading doc comment: within one sandboxed execution the envelope's renderDepth is always 0, so depth could never exceed 1 regardless of enforcement — it couldn't have caught this recursion anyway. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sandbox): fixed linting issue * fix(sandbox): fixed linting issue --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: kwburns-kong <kyle.burns@konghq.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does