Skip to content

Commit cc9288c

Browse files
jackkavkwburns-kongclaude
authored
feat(templating): QuickJS template-tag sandbox behind a flag, with e2e 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>
1 parent 9d6d712 commit cc9288c

21 files changed

Lines changed: 1376 additions & 2 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# insomnia-plugin-sandbox-demo
2+
3+
Manual test fixture for the QuickJS template-tag sandbox (Milestone 2).
4+
5+
The `sandboxprobe` tag reports **where** it executed and exercises an async host bridge:
6+
7+
- Sandbox flag **off**`hello | ran in: main-process | arch via bridge: <arch>`
8+
- Sandbox flag **on**`hello | ran in: sandbox | arch via bridge: <arch>`
9+
10+
`ran in` flips because Node's `process` global exists in the legacy main-process path but is
11+
absent inside QuickJS. `arch via bridge` proves `context.util.nodeOS()` round-tripped through
12+
`__hostBridge``pluginToMainAPI['nodeOS']` and back.
13+
14+
## Install (dev)
15+
16+
1. Run the app: `npm run dev` (repo root).
17+
2. Preferences → Plugins → **Reveal Plugins Folder**.
18+
3. Copy this `insomnia-plugin-sandbox-demo` folder into that directory.
19+
4. Click **Reload Plugins**.
20+
5. Preferences → Scripting → toggle **Run template tags in sandbox (experimental)**.
21+
6. In a request URL/header, insert the `Sandbox Probe` template tag (or type `{% sandboxprobe 'hi' %}`),
22+
and watch the preview change as you toggle the flag.
23+
24+
Note: this cut's `require` shim only supports `path` and `crypto`. A plugin that `require()`s
25+
an npm package, another builtin, or a relative file will throw a clear
26+
`Cannot find module ... in sandbox` — broader coverage is follow-up work.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// eslint-disable-next-line no-undef
2+
module.exports.templateTags = [
3+
{
4+
name: 'sandboxprobe',
5+
displayName: 'Sandbox Probe',
6+
description: 'Reports whether it executed inside the QuickJS sandbox, and exercises an async bridge',
7+
args: [
8+
{ displayName: 'Label', type: 'string', defaultValue: 'hello' },
9+
],
10+
async run(context, label = 'hello') {
11+
// In the QuickJS sandbox, Node globals like `process` are absent; in the legacy main-process
12+
// path they exist. This makes the chosen execution path directly observable in the output.
13+
const ranIn = typeof process === 'undefined' ? 'sandbox' : 'main-process';
14+
15+
// Exercise an async host bridge — proves __hostBridge + the executePendingJobs driver loop
16+
// round-trip work end-to-end (context.util.nodeOS -> pluginToMainAPI['nodeOS']).
17+
let arch = 'n/a';
18+
try {
19+
const os = await context.util.nodeOS();
20+
arch = os.arch;
21+
} catch (err) {
22+
arch = 'bridge-error:' + err.message;
23+
}
24+
25+
return `${label} | ran in: ${ranIn} | arch via bridge: ${arch}`;
26+
},
27+
},
28+
];
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "insomnia-plugin-sandbox-demo",
3+
"version": "1.0.0",
4+
"description": "Demo plugin to manually verify the QuickJS template-tag sandbox path",
5+
"main": "index.js",
6+
"insomnia": {
7+
"name": "sandbox-demo",
8+
"description": "Demo plugin to manually verify the QuickJS template-tag sandbox path"
9+
}
10+
}

package-lock.json

Lines changed: 68 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/insomnia-data/common-src/settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ export interface Settings {
166166
scriptSandboxEnabled: boolean;
167167
// Wraps the user script in 'use strict', preventing accidental globals and making `this` undefined.
168168
scriptStrictModeEnabled: boolean;
169+
// Experimental: execute plugin template tags inside the QuickJS-WASM sandbox instead of directly in the main process.
170+
templateTagSandboxEnabled: boolean;
169171
// Names of security rules that have been individually disabled.
170172
disabledSecurityRules: string[];
171173
// AST blocked-property names that have been individually disabled.

packages/insomnia-data/src/models/settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export function init(): BaseSettings {
8484
dataFolders: [],
8585
scriptSandboxEnabled: true,
8686
scriptStrictModeEnabled: true,
87+
templateTagSandboxEnabled: false,
8788
disabledSecurityRules: [],
8889
disabledBlockedProperties: [],
8990
disabledBlockedRoots: [],
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
type: collection.insomnia.rest/5.0
2+
schema_version: "5.1"
3+
name: Sandbox Probe Collection
4+
meta:
5+
id: wrk_5a4db0c5000000000000000000000001
6+
created: 1751800000000
7+
modified: 1751800000000
8+
collection:
9+
- url: http://127.0.0.1:4010/echo
10+
name: Sandbox Probe
11+
meta:
12+
id: req_5a4db0c5000000000000000000000002
13+
created: 1751800000001
14+
modified: 1751800000001
15+
isPrivate: false
16+
sortKey: -1751800000001
17+
method: GET
18+
body:
19+
mimeType: text/plain
20+
text: |
21+
{% sandboxprobe 'e2e' %}
22+
{% cryptoparity 'insomnia-test' %}
23+
headers:
24+
- name: Content-Type
25+
value: text/plain
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import crypto from 'node:crypto';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
5+
import { expect } from '@playwright/test';
6+
7+
import { loadFixture } from '../../playwright/paths';
8+
import { test } from '../../playwright/test';
9+
10+
const PLUGIN_NAME = 'insomnia-plugin-sandbox-probe';
11+
12+
// Write a probe plugin into the data-path plugins directory (same pattern as plugin-bridge.test.ts).
13+
// - sandboxprobe: reports which execution path ran it (Node's `process` global exists in the legacy
14+
// main-process path but is absent inside QuickJS) and exercises an async host-bridge round-trip.
15+
// - cryptoparity: a deterministic require('crypto') workload whose output must be byte-identical
16+
// between the legacy path and the sandbox path.
17+
const installProbePlugin = (dataPath: string) => {
18+
const pluginDir = path.join(dataPath, 'plugins', PLUGIN_NAME);
19+
fs.mkdirSync(pluginDir, { recursive: true });
20+
fs.writeFileSync(
21+
path.join(pluginDir, 'package.json'),
22+
// The 'insomnia' key is required — the loader skips packages that lack it.
23+
JSON.stringify({ name: PLUGIN_NAME, version: '1.0.0', main: 'index.js', insomnia: {} }),
24+
);
25+
fs.writeFileSync(
26+
path.join(pluginDir, 'index.js'),
27+
`
28+
module.exports.templateTags = [
29+
{
30+
name: 'sandboxprobe',
31+
displayName: 'Sandbox Probe',
32+
description: 'Reports whether it executed inside the QuickJS sandbox',
33+
args: [{ displayName: 'Label', type: 'string', defaultValue: 'hello' }],
34+
async run(context, label = 'hello') {
35+
const ranIn = typeof process === 'undefined' ? 'sandbox' : 'main-process';
36+
let arch = 'n/a';
37+
try {
38+
const hostOS = await context.util.nodeOS();
39+
arch = hostOS.arch;
40+
} catch (err) {
41+
arch = 'bridge-error:' + err.message;
42+
}
43+
return label + ' | ran in: ' + ranIn + ' | arch via bridge: ' + arch;
44+
},
45+
},
46+
{
47+
name: 'cryptoparity',
48+
displayName: 'Crypto Parity',
49+
description: 'Deterministic crypto workload for flag-on/off parity',
50+
args: [{ displayName: 'Input', type: 'string', defaultValue: 'insomnia-test' }],
51+
async run(context, input = 'insomnia-test') {
52+
return require('crypto').createHash('sha256').update(String(input)).digest('hex');
53+
},
54+
},
55+
];
56+
`,
57+
);
58+
};
59+
60+
test('Template tag sandbox: flag routes plugin tag execution into the QuickJS sandbox', async ({
61+
page,
62+
app,
63+
dataPath,
64+
insomnia,
65+
}) => {
66+
installProbePlugin(dataPath);
67+
68+
// The persistent "Plugin system updated" notification fires from a Root mount effect whenever
69+
// user plugins exist on disk, and its toast region intercepts pointer events over the page.
70+
// Seed its once-only guard so route remounts can't re-raise it, then clear any toast already
71+
// in flight before driving the UI.
72+
await page.getByLabel('Import').waitFor();
73+
await page.evaluate(() => localStorage.setItem('plugin-system-changes-toast-shown', 'true'));
74+
const dismissButtons = page.getByRole('button', { name: 'Dismiss' });
75+
await dismissButtons
76+
.first()
77+
.waitFor({ timeout: 3000 })
78+
.catch(() => {});
79+
while ((await dismissButtons.count()) > 0) {
80+
await dismissButtons.first().click();
81+
}
82+
83+
// Import a collection whose request body contains the probe tags (the tags render as pills
84+
// lexically, so importing before the plugin is registered is fine).
85+
const fixture = await loadFixture('sandbox-probe-collection.yaml');
86+
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), fixture);
87+
await page.getByLabel('Import').click();
88+
await page.locator('[data-test-id="import-from-clipboard"]').click();
89+
await page.getByRole('button', { name: 'Scan' }).click();
90+
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
91+
92+
// Register the probe plugin through the bridge.
93+
await page.evaluate(() => (window as any).main.plugins.reloadPlugins());
94+
95+
await insomnia.navigationSidebar.clickRequestOrFolder('Sandbox Probe');
96+
await page.getByText('Body', { exact: true }).click();
97+
98+
// Open a tag pill in the body editor and assert its Live Preview output (auto-retries until the
99+
// render completes), then close the modal.
100+
const assertTagPreview = async (tagPrefix: string, expected: string) => {
101+
await page.locator(`[data-template^="${tagPrefix}"]`).click();
102+
const modal = page.getByRole('dialog');
103+
await expect.soft(modal.getByLabel('Live Preview')).toContainText(expected);
104+
await modal.getByRole('button', { name: 'Done' }).click();
105+
};
106+
107+
const expectedHash = crypto.createHash('sha256').update('insomnia-test').digest('hex');
108+
109+
// Flag off (default): tags run on the legacy main-process path.
110+
await assertTagPreview('{% sandboxprobe', 'e2e | ran in: main-process');
111+
await assertTagPreview('{% cryptoparity', expectedHash);
112+
113+
// Toggle the sandbox on: Preferences → Scripting → "Run template tags in sandbox".
114+
await page.getByTestId('settings-button').click();
115+
await page.getByRole('tab', { name: 'Scripting' }).click();
116+
const sandboxToggle = page.getByTestId('toggle-template-tag-sandbox');
117+
await sandboxToggle.click();
118+
await expect.soft(sandboxToggle.getByRole('switch')).toBeChecked();
119+
await page.locator('.app').press('Escape');
120+
121+
// Canary: the same tag now reports sandbox execution, and the async host bridge still round-trips.
122+
// Derive the expected arch from the Electron main process (where pluginToMainAPI runs) rather
123+
// than the Playwright runner, which can differ in cross-arch setups.
124+
const electronArch = await app.evaluate(() => process.arch);
125+
await assertTagPreview('{% sandboxprobe', `e2e | ran in: sandbox | arch via bridge: ${electronArch}`);
126+
127+
// Parity: the sandboxed require('crypto') workload is byte-identical to the legacy render above.
128+
await assertTagPreview('{% cryptoparity', expectedHash);
129+
});

packages/insomnia/esbuild.entrypoints.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ export default async function build(options: Options) {
140140
external: [
141141
'electron',
142142
'@getinsomnia/node-libcurl',
143+
// QuickJS loads its .wasm relative to its own __dirname; bundling it breaks that path
144+
// resolution. Keep it external so it's required from node_modules at runtime (like node-libcurl).
145+
'quickjs-emscripten',
146+
'quickjs-emscripten-core',
147+
'@jitl/*',
143148
'fsevents',
144149
'@node-llama-cpp/mac-arm64-metal',
145150
'@node-llama-cpp/mac-x64',

packages/insomnia/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@
7272
"acorn": "^8.16.0",
7373
"acorn-walk": "^8.3.5",
7474
"ajv": "^8.17.1",
75-
"assert": "^2.1.0",
7675
"apiconnect-wsdl": "2.0.36",
76+
"assert": "^2.1.0",
7777
"aws4": "^1.13.2",
7878
"blakejs": "^1.2.1",
7979
"buffer": "^6.0.3",
@@ -130,6 +130,7 @@
130130
"oauth-1.0a": "^2.2.6",
131131
"objectpath": "^2.0.0",
132132
"papaparse": "^5.5.2",
133+
"quickjs-emscripten": "^0.32.0",
133134
"react": "^18.3.1",
134135
"react-aria": "3.43.2",
135136
"react-aria-components": "^1.12.2",

0 commit comments

Comments
 (0)