Skip to content

PoC: run plugin template tags in a QuickJS-WASM sandbox#10095

Draft
jackkav wants to merge 5 commits into
Kong:developfrom
jackkav:claude/admiring-mcclintock-8be6be
Draft

PoC: run plugin template tags in a QuickJS-WASM sandbox#10095
jackkav wants to merge 5 commits into
Kong:developfrom
jackkav:claude/admiring-mcclintock-8be6be

Conversation

@jackkav

@jackkav jackkav commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Draft / PoC for review — not for merge. Behind a default-off flag.

What & why

Plugin template tags (getTemplateTags) currently execute with full, unsandboxed Node access in the main process — the worker routes each tag's run() over IPC to main/templating-worker-database.ts, which calls it directly. This PoC runs that run() inside a QuickJS-WASM sandbox instead, exposing a single bridged plugin API. Templating itself never evals code (LiquidJS is pure string work) — only plugin run() functions need isolation.

Based on the marshaling approach validated in #10072.

Approach

  • bulk-copy in / bridge async out (per spike: QuickJS-WASM sandbox marshaling cost harness #10072): render state (args, render scope, meta, renderPurpose) is JSON-copied into the sandbox; the context API is rebuilt in pure JS inside the sandbox; only genuinely-async work bridges back to the host via the existing pluginToMainAPI handlers — reused verbatim.
  • sync QuickJS module + VM-promise driver loop (executePendingJobs), not the asyncified variant (spike: QuickJS-WASM sandbox marshaling cost harness #10072 found asyncify breaks on suspended awaits).
  • node:crypto via sync host functions. Plugins call crypto synchronously (createHash(x).digest()), so it can't go through the async bridge. Since the sandbox runs in main (Node), crypto is exposed as synchronous host functions backed by real node:crypto — correct + secure, no hand-rolled crypto. (Revisit when/if the sandbox moves off the main process.)
  • QuickJS kept external in esbuild so its .wasm resolves at runtime (bundling broke __dirname resolution).

Scope

  • Template tags only. Request/response hooks, plugin actions, and pre/post-request scripts are out of scope (designed for later reuse).
  • Sandbox runs in main; the renderer→worker→IPC path is unchanged — only the main-side execute handlers change.
  • Gated by a new templateTagSandboxEnabled setting (default off), toggle in Scripting settings. Legacy path untouched when off.
  • require shim covers path and crypto; anything else throws a clear Cannot find module 'X' in sandbox (npm deps / relative requires / other builtins are follow-up work).

Files

  • src/templating/sandbox/quickjs-runtime, marshal, host-bridge, in-sandbox-bootstrap, plugin-tag-sandbox (+ tests)
  • src/main/templating-worker-database.ts — route execute handlers through the sandbox behind the flag
  • esbuild.entrypoints.ts — quickjs externalized in the main build
  • settings (insomnia-data) + scripting-settings.tsx toggle
  • examples/insomnia-plugin-sandbox-demo/ — manual E2E fixture

Tests / verification

  • src/templating/sandbox/plugin-tag-sandbox.test.ts — 21 cases: base64 parity (exercises btoa/atob/TextEncoder/TextDecoder polyfills), os async-bridge round-trip, require('crypto') parity vs node:crypto (sha256/md5/sha1/sha512/hmac, randomUUID/randomBytes), and error propagation. All pass.
  • Manual: install examples/insomnia-plugin-sandbox-demo, toggle the flag, render {% sandboxprobe 'hi' %} — output flips between ran in: main-process and ran in: sandbox.

Known limitations / follow-ups

  • require shim is minimal (path + crypto) — broaden coverage (npm deps, relative files, more builtins).
  • Sandbox in main for now; moving templating off the Web Worker and collapsing the two engine paths is later work.
  • Response/Buffer marshaling is stubbed; util.render recursion bridges but is lightly exercised.

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 Kong#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).

it('createHmac sha256 hex', async () => {
const source = cryptoTag("return crypto.createHmac('sha256', 'my-secret').update(input).digest('hex');");
const expected = nodeCrypto.createHmac('sha256', 'my-secret').update('payload', 'utf8').digest('hex');
kwburns-kong and others added 2 commits June 17, 2026 13:14
Three security fixes to the plugin template-tag sandbox (gated behind the
default-off templateTagSandboxEnabled setting):

- Namespace segmentation: capture the host-injected __hostBridge / __crypto*
  / __hostConsole globals into closure locals and delete them from globalThis
  at the end of the bootstrap, so plugin code can reach only the rebuilt
  context object, not the raw bridge.
- Host-side plugin identity: add scopePluginDataHandlers() to force the
  trusted pluginName onto pluginData.* bridge calls, preventing a tag from
  forging another plugin's name to read/clear its store.
- DoS guards: install an interrupt handler (shouldInterruptAfterDeadline) and
  a memory limit on the QuickJS runtime so a synchronous infinite loop is
  interrupted within the deadline instead of hanging the process.

Adds namespace-segmentation.test.ts covering the above (global hidden,
direct-bridge denial, cross-plugin forge override, infinite-loop interrupt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four defensive fixes at the main-side sandbox bridge (all within the PR's
"only main-side execute handlers change" scope):

- util.render depth cap (capUtilRenderDepth): reject nested util.render once
  the sandbox-reported depth exceeds a limit; a cheap guard layered on the
  runtime interrupt/memory limits.
- Error-message sanitization: redact absolute host filesystem paths from
  bridge error messages before they cross back into the sandbox.
- Prototype-pollution guard: strip __proto__/constructor/prototype keys when
  JSON-parsing the attacker-controlled bridge body.
- Payload size caps: reject oversized request/response bridge payloads
  (configurable via maxPayloadBytes; defaults to 8 MiB).

Adds bridge-boundary-validation.test.ts (10 cases) covering each fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jackkav added 2 commits June 18, 2026 11:30
fix(poc-sandbox): namespace segmentation
Toggle the templateTagSandboxEnabled setting and verify a user plugin
template tag executes inside the QuickJS-WASM sandbox with a working
node crypto shim. Asserts the tag flips from main-process to sandbox
execution while producing an identical sha256 digest via require('crypto').

Adds a data-testid to the Scripting settings toggle for a stable locator.
@jackkav

jackkav commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

E2E verified against a fully-packaged build ✅

Added packages/insomnia-smoke-test/tests/smoke/template-tag-sandbox.test.ts, which:

  1. Installs a user plugin whose template tag does require('crypto'), sha256-hashes a fixed string, and reports its execution context (process is absent inside the QuickJS sandbox, present in main).
  2. Renders the tag with templateTagSandboxEnabled off → asserts main-process|<sha256>.
  3. Toggles the Run template tags in sandbox switch on in Scripting settings.
  4. Renders again → asserts sandbox|<sha256> — same digest, different execution path.

This proves the toggle does what it claims and the node:crypto shim produces an identical digest inside the sandbox.

Results

  • Dev build (npm run test:smoke:dev): ✅ passing (20.7s)
  • Packaged build (npm run app-packagenpm run test:smoke:package): ✅ passing (10.8s)

The packaged run is the important one here — it exercises the externalized QuickJS .wasm resolving at runtime (the bundling concern called out in the PR description), confirming crypto works in a real packaged app, not just under Vite.

Note: locally the packaged universal-mac app needed an ad-hoc codesign --force --deep --sign - before launch (Team-ID mismatch between the main binary and the Electron Framework in an unsigned local build). CI runners don't hit this. The test itself is unmodified between dev and packaged runs.

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.

3 participants