fix(templating): re-verify symlink containment on response.setBody writes (#10301)

* feat(templating): (A1) in-sandbox plugin-action API + marshaling

Adds the sandbox core for routing user-plugin actions (request/requestGroup/
workspace/document) through the QuickJS sandbox, mirroring the H1 hook path.

- marshal.ts: envelope gains actionKind/actionLabel/actionDomainData. An action
  is fire-and-effect — it reads the copied-in domain models and performs side
  effects only through the capability-gated context.* bridge; nothing marshals back.
- in-sandbox-bootstrap.ts: __invokeAction() rebuilds context via __buildContext,
  resolves the kind-specific action list, finds the entry by label, and calls
  action(context, actionDomainData); ACTION_RUNNER drives it. Locked alongside
  the other internals.
- plugin-tag-sandbox.ts: runner selection picks ACTION_RUNNER when actionKind is set.
- sandbox-surface.ts + snapshot: __invokeAction added to the reviewed internal set.
- sandbox-actions.test.ts: label match + domainData passthrough, capability gating
  (ungranted context.network absent), missing-label error, kind-specific list.

Host wiring (plugin.runUserAction handler, invoke-method routing, e2e) follows.

* feat(templating): (A1) route user-plugin actions through the sandbox

Host + plugin-window wiring for the in-sandbox action API.

- templating-worker-database.ts: runActionInSandbox() mirrors runRequestHookInSandbox
  (same bridge/grants/crypto), building an action envelope; nothing is marshaled back.
  New protocol handler plugin.runUserAction resolves the plugin via resolveTrustedPlugin
  (registry-trusted directory + permissions, not caller-supplied) before dispatch.
- types.ts: PluginToMainAPIPaths gains 'plugin.runUserAction'.
- invoke-method.ts (plugin window): executeAction routes user plugins (directory !== '')
  through plugin.runUserAction when the sandbox is enabled; bundle plugins and the
  flag-off path stay in-process. Tag actions (templateTag.actions) are unchanged — the
  L1 manifest does not discover them, so they remain a flag-off surface.

Actions are UI-triggered only, so there is no node-runtime/inso path to gate.

* test(templating): (A1) e2e — user-plugin request action runs in the sandbox

A request action writes a path canary (INSOMNIA_TEMPLATE_SANDBOX) plus whether it
received the domain models into plugin storage; a sibling template tag reads it back
(actions are fire-and-effect, so the canary rides the side effect). Flag off → the
action runs in-process; flag on → the same action runs in the sandbox (its in-process
fn is a throw-stub after discovery, so a successful write proves routing) and the
domain models still reach it. Asserts on unique canary values, not header names.

* fix(templating): re-verify symlink containment on response.setBody writes

Mirrors the realpath re-check getPluginEntrySource/readPluginModuleMap
already apply, so a symlinked directory under responses/ can't redirect
a write outside it despite the string-based containment check passing.

---------

Co-authored-by: jackkav <jackkav@gmail.com>
This commit is contained in:
kwburns-kong
2026-07-31 02:01:42 -04:00
committed by GitHub
parent 2b16d37dda
commit dbca2b1654
2 changed files with 47 additions and 0 deletions

View File

@@ -514,6 +514,19 @@ describe('plugin.runUserResponseHook: setBody cannot redirect its write onto a d
expect(fs.readFileSync(otherBodyPath, 'utf8')).toBe('other-original-body');
});
it('rejects a bodyPath reached through a symlinked directory that resolves outside the responses directory', async () => {
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-outside-'));
const linkedDir = path.join(responsesDir, 'linked');
fs.symlinkSync(outsideDir, linkedDir, 'dir');
const throughSymlink = path.join(linkedDir, 'escaped-body.txt');
try {
await runResponseHook(throughSymlink, 'req_1');
expect(fs.existsSync(path.join(outsideDir, 'escaped-body.txt'))).toBe(false);
} finally {
fs.rmSync(outsideDir, { recursive: true, force: true });
}
});
// Same check, reached by sending the path-normalization variant directly to response.setBody.
it('rejects a path-normalization variant sent directly to response.setBody', async () => {
const { services } = await import('insomnia-data');
@@ -539,6 +552,34 @@ describe('plugin.runUserResponseHook: setBody cannot redirect its write onto a d
expect(json.error).toMatch(/belongs to a different response/);
expect(fs.readFileSync(otherBodyPath, 'utf8')).toBe('other-original-body');
});
// Same check, reached by sending a bodyPath through a symlinked directory directly to response.setBody.
it('rejects a bodyPath through a symlinked directory sent directly to response.setBody', async () => {
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-outside-'));
const linkedDir = path.join(responsesDir, 'linked');
fs.symlinkSync(outsideDir, linkedDir, 'dir');
const throughSymlink = path.join(linkedDir, 'escaped-body.txt');
const token = getOrCreateTemplatingDbAuthToken();
const { resolveDbByKey } = await import('../templating-worker-database');
try {
const req = new Request('insomnia-templating-worker-database://response.setbody', {
method: 'POST',
headers: { [TEMPLATING_DB_AUTH_HEADER]: token },
body: JSON.stringify({
bodyPath: throughSymlink,
bodyBase64: Buffer.from('replacement-body', 'utf8').toString('base64'),
parentId: 'req_1',
}),
});
const res = await resolveDbByKey(req);
expect(res.status).toBe(500);
const json = await res.json();
expect(json.error).toMatch(/escapes the responses directory/);
expect(fs.existsSync(path.join(outsideDir, 'escaped-body.txt'))).toBe(false);
} finally {
fs.rmSync(outsideDir, { recursive: true, force: true });
}
});
});
describe('discoverPluginExportsInSandbox strips dangerous JSON keys, symmetric with the hook path', () => {

View File

@@ -707,6 +707,12 @@ export const pluginToMainAPI: Record<PluginToMainAPIPaths, (...args: any[]) => P
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('response.setBody path escapes the responses directory');
}
// Re-check after resolving symlinks, mirroring getPluginEntrySource/readPluginModuleMap: a
// symlinked directory entry under responsesDir could otherwise resolve outside it despite the
// string-based check above passing.
if (!isContainedIn(fs.realpathSync(responsesDir), fs.realpathSync(path.dirname(target)))) {
throw new Error('response.setBody path escapes the responses directory');
}
fs.writeFileSync(target, Buffer.from(body.bodyBase64, 'base64'));
return null;
},