mirror of
https://github.com/Kong/insomnia.git
synced 2026-08-02 10:49:11 -04:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user