From dbca2b1654866e8dce836e723e1bdf21168da1c5 Mon Sep 17 00:00:00 2001 From: kwburns-kong Date: Fri, 31 Jul 2026 02:01:42 -0400 Subject: [PATCH] fix(templating): re-verify symlink containment on response.setBody writes (#10301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- ...er-database-protocol-authorization.test.ts | 41 +++++++++++++++++++ .../src/main/templating-worker-database.ts | 6 +++ 2 files changed, 47 insertions(+) diff --git a/packages/insomnia/src/main/__tests__/templating-worker-database-protocol-authorization.test.ts b/packages/insomnia/src/main/__tests__/templating-worker-database-protocol-authorization.test.ts index d12e969f82..7c23b1eee5 100644 --- a/packages/insomnia/src/main/__tests__/templating-worker-database-protocol-authorization.test.ts +++ b/packages/insomnia/src/main/__tests__/templating-worker-database-protocol-authorization.test.ts @@ -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', () => { diff --git a/packages/insomnia/src/main/templating-worker-database.ts b/packages/insomnia/src/main/templating-worker-database.ts index b05210d5f0..0f48408248 100644 --- a/packages/insomnia/src/main/templating-worker-database.ts +++ b/packages/insomnia/src/main/templating-worker-database.ts @@ -707,6 +707,12 @@ export const pluginToMainAPI: Record 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; },