mirror of
https://github.com/Kong/insomnia.git
synced 2026-10-05 20:42:37 -04:00
feat(templating): (T1) flip trust model — pluginSandboxEnabled + per-plugin elevated opt-in (#10318)
* feat(templating): (T1) pluginSandboxEnabled + per-plugin elevated opt-in, centralized gate Introduces the trust-model flip's core logic and wires every untrusted-execution surface to it. - New setting `pluginSandboxEnabled` (default off) that supersedes/absorbs `templateTagSandboxEnabled`: either flag on activates the sandbox, so existing template-tag opt-ins keep working (migration bridge). - New per-plugin `pluginConfig.elevated` escape hatch: a user plugin marked elevated runs in-process with full host access even while the sandbox is on. Widened PluginConfig/PluginConfigMap/Plugin.config. - New pure resolver `common/plugins/sandbox-mode.ts` (isSandboxEnabled / resolvePluginExecutionMode / shouldSandboxPlugin) — the single source of truth replacing the 7 duplicated `templateTagSandboxEnabled && directory !== ''` conditions. Dependency-free so main, the plugin window, and the inso CLI node runtime all share it. Unit-tested (12 cases). - Wired all surfaces to shouldSandboxPlugin: load-time discovery (now per-plugin, so an elevated plugin is nodeRequire-d for live functions), request/response hooks (plugin-window + node runtime), actions, and user template tags. Bundle-tag path reads isSandboxEnabled (bundle stays trusted). No UI yet (next commit); default-off means no behavior change until a flag is toggled. * feat(templating): (T1) Preferences UI — plugin-sandbox toggle + per-plugin elevated + mode indicator - Scripting settings: new "Sandbox all plugin code (experimental)" toggle for pluginSandboxEnabled (data-testid toggle-plugin-sandbox), beside the existing template-tag toggle. - Plugins settings: each user plugin card now shows its resolved execution mode (Sandboxed / Elevated / In-process, data-testid plugin-mode-<name>) and a "Full host access" checkbox (data-testid plugin-elevated-<name>) that writes pluginConfig.<name>.elevated. Mode + toggle read from live settings so they update immediately, before the plugin list reloads. - Widened SerializablePlugin.config to carry the optional `elevated` flag through the bridge. * test(templating): (T1) e2e — pluginSandboxEnabled sandboxes a user plugin; elevating runs it in-process Composes the trust-flip's two user-visible behaviors on one action-probe plugin: - Enabling the new pluginSandboxEnabled toggle (not the legacy template-tag flag) routes the user plugin's action into the sandbox (canary reports ranin-sandboxed). - Toggling "Full host access" in Preferences → Plugins flips the mode indicator to "Elevated" and the same action then runs in-process (marker absent, ranin-mainprocess) — the per-plugin escape hatch. Reuses the sandbox-action-collection.yaml fixture and the established writePlugin/clearPluginToast helpers; adds enablePluginSandbox + setPluginElevated helpers. * fix(templating): (T1) close two plugin-registry trust-cache gaps (#10326) * fix(templating): close two plugin-registry trust-cache gaps - pluginConfig.elevated is keyed by declared plugin name, not by folder; a same-named folder placed alongside an already-elevated plugin inherited its trust grant and ran in-process before any collision was even noticed. traversePluginPath now pre-scans for duplicate names (order-independent) and refuses to load any colliding folder. - applyRequestHooks/applyResponseHooks tagged a caught error with `error.plugin = plugin`, a plain assignment that a plugin-thrown Error could intercept via its own `plugin` property setter, handing the hook a live, mutable reference to its own cached registry entry and letting it flip `directory`/`config.elevated` to defeat later sandboxing. Switched to Object.defineProperty, which bypasses any such setter. * fix(plugins): use relative-path containment check instead of startsWith A bare .startsWith(base) on a resolved path accepts a sibling directory whose name happens to prefix-match the base (e.g. /plugins-evil vs /plugins). Added a shared isContainedIn helper (path.relative, rejects .. or an absolute result) and applied it to both the existing plugin-path containment check and the new duplicate-name pre-pass. * fix(templating): (T1) address Copilot review — skip CLI settings read, scope bundle-tag sandbox to legacy flag - network-adapter.node.ts: only read services.settings.get() when canSandbox (Electron). The pure-Node inso CLI can never reach the sandbox host, so the read was wasted work; pass undefined otherwise (shouldSandboxPlugin treats it as off). Both applyRequestHooks and applyResponseHooks. - templating-worker-database.ts: bundle (first-party/trusted) template tags now sandbox only under the legacy templateTagSandboxEnabled experiment, not the new pluginSandboxEnabled. The T1 flag isolates *untrusted* plugins; sandboxing trusted bundle tags under it was an unintended behavior change. Dropped the now-unused isSandboxEnabled import. --------- Co-authored-by: kwburns-kong <kyle.burns@konghq.com>
This commit is contained in:
1 parent
e906a6a0df
commit
e759bbceaf
16 files changed
+711
-36
No files matched your search
@@ -95,9 +95,12 @@ export type SettingsOfType<MatchType> = NonNullable<
|
||||
|
||||
export interface PluginConfig {
|
||||
disabled: boolean;
|
||||
// T1: per-plugin escape hatch. When true, a user plugin runs in-process with full host access
|
||||
// instead of the sandbox, even while the sandbox is enabled. Off/absent = sandboxed (default-deny).
|
||||
elevated?: boolean;
|
||||
}
|
||||
|
||||
export type PluginConfigMap = Record<string, { disabled: boolean }>;
|
||||
export type PluginConfigMap = Record<string, PluginConfig>;
|
||||
|
||||
export interface Settings {
|
||||
autoDetectColorScheme: boolean;
|
||||
@@ -168,6 +171,11 @@ export interface Settings {
|
||||
scriptStrictModeEnabled: boolean;
|
||||
// Experimental: execute plugin template tags inside the QuickJS-WASM sandbox instead of directly in the main process.
|
||||
templateTagSandboxEnabled: boolean;
|
||||
// T1: sandbox ALL untrusted (user) plugin surfaces — template tags, request/response hooks, actions,
|
||||
// and load-time module code. Supersedes templateTagSandboxEnabled: either flag on activates the
|
||||
// sandbox (migration bridge). User plugins are default-deny; per-plugin `pluginConfig.elevated` opts
|
||||
// an individual plugin back into full-host in-process execution. Bundle plugins are always trusted.
|
||||
pluginSandboxEnabled: boolean;
|
||||
// Names of security rules that have been individually disabled.
|
||||
disabledSecurityRules: string[];
|
||||
// AST blocked-property names that have been individually disabled.
|
||||
|
||||
@@ -85,6 +85,7 @@ export function init(): BaseSettings {
|
||||
scriptSandboxEnabled: true,
|
||||
scriptStrictModeEnabled: true,
|
||||
templateTagSandboxEnabled: false,
|
||||
pluginSandboxEnabled: false,
|
||||
disabledSecurityRules: [],
|
||||
disabledBlockedProperties: [],
|
||||
disabledBlockedRoots: [],
|
||||
|
||||
@@ -137,7 +137,11 @@ const clearPluginToast = async (page: Page) => {
|
||||
.catch(() => {});
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline && (await dismissButtons.count()) > 0) {
|
||||
await dismissButtons.first().click({ force: true, timeout: 500 }).catch(() => {});
|
||||
// eslint-disable-next-line playwright/no-force-option -- necessary to avoid flakiness with re-rendering toast
|
||||
await dismissButtons
|
||||
.first()
|
||||
.click({ force: true, timeout: 500 })
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -892,7 +896,9 @@ test('Plugin action sandbox (A1): a user plugin request action runs in the sandb
|
||||
// Flag OFF (default): the action runs in-process (control). Its write is observable via the tag,
|
||||
// and the domain models reached it.
|
||||
await runAction();
|
||||
await expect.poll(() => readTagPreview('{% actionreadback'), { timeout: 20_000 }).toContain('ranin-mainprocess|got-req');
|
||||
await expect
|
||||
.poll(() => readTagPreview('{% actionreadback'), { timeout: 20_000 })
|
||||
.toContain('ranin-mainprocess|got-req');
|
||||
|
||||
// Flag ON: the same action now runs in the QuickJS sandbox (its in-process fn is a throw-stub after
|
||||
// discovery, so a successful write proves routing). Poll run+readback so the just-toggled setting
|
||||
@@ -908,3 +914,187 @@ test('Plugin action sandbox (A1): a user plugin request action runs in the sandb
|
||||
)
|
||||
.toContain('ranin-sandboxed|got-req');
|
||||
});
|
||||
|
||||
// T1: enable the *new* pluginSandboxEnabled flag (distinct from templateTagSandboxEnabled) via
|
||||
// Preferences → Scripting. Same soft-assertion + Escape-to-close shape as enableSandbox.
|
||||
const enablePluginSandbox = async (page: Page) => {
|
||||
await page.getByTestId('settings-button').click();
|
||||
const toggle = page.getByTestId('toggle-plugin-sandbox');
|
||||
await page.getByRole('tab', { name: 'Scripting' }).click();
|
||||
await toggle.getByRole('switch').waitFor();
|
||||
await toggle.click();
|
||||
await expect.soft(toggle.getByRole('switch')).toBeChecked();
|
||||
await page.locator('.app').press('Escape');
|
||||
await expect.soft(page.getByTestId('toggle-plugin-sandbox')).toBeHidden();
|
||||
};
|
||||
|
||||
// T1: toggle a user plugin's "Full host access" (elevated) in Preferences → Plugins, assert the mode
|
||||
// indicator reflects the new mode, then close. Toggling pluginConfig triggers a plugin reload (the
|
||||
// Plugins pane re-discovers on settings.pluginConfig change), so the plugin is re-loaded elevated.
|
||||
const setPluginElevated = async (page: Page, pluginName: string, expectedMode: string) => {
|
||||
await page.getByTestId('settings-button').click();
|
||||
await page.getByRole('tab', { name: 'Plugins' }).click();
|
||||
const elevatedToggle = page.getByTestId(`plugin-elevated-${pluginName}`);
|
||||
await elevatedToggle.waitFor();
|
||||
await elevatedToggle.click();
|
||||
await expect.soft(page.getByTestId(`plugin-mode-${pluginName}`)).toHaveText(expectedMode);
|
||||
await page.locator('.app').press('Escape');
|
||||
await expect.soft(elevatedToggle).toBeHidden();
|
||||
};
|
||||
|
||||
test('Plugin sandbox trust flip (T1): pluginSandboxEnabled sandboxes a user plugin; elevating it runs it in-process', async ({
|
||||
page,
|
||||
app,
|
||||
dataPath,
|
||||
insomnia,
|
||||
}) => {
|
||||
// One plugin, two surfaces sharing its store: a request action records where it ran (the sandbox
|
||||
// marker INSOMNIA_TEMPLATE_SANDBOX exists only in the sandbox), and a template tag reads it back.
|
||||
// `storage` is declared so context.store is granted on both the sandboxed and elevated paths.
|
||||
writePlugin(
|
||||
dataPath,
|
||||
'insomnia-plugin-t1-probe',
|
||||
{ permissions: { capabilities: ['storage'] } },
|
||||
`
|
||||
module.exports.requestActions = [
|
||||
{
|
||||
label: 'T1 Probe',
|
||||
async action(context) {
|
||||
var ranIn = typeof INSOMNIA_TEMPLATE_SANDBOX !== 'undefined' ? 'ranin-sandboxed' : 'ranin-mainprocess';
|
||||
await context.store.setItem('t1_ran_in', ranIn);
|
||||
},
|
||||
},
|
||||
];
|
||||
module.exports.templateTags = [
|
||||
{
|
||||
name: 'actionreadback', displayName: 'actionreadback', args: [],
|
||||
async run(context) {
|
||||
return (await context.store.getItem('t1_ran_in')) || 'unset';
|
||||
},
|
||||
},
|
||||
];
|
||||
`,
|
||||
);
|
||||
|
||||
const fixture = await loadFixture('sandbox-action-collection.yaml');
|
||||
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), fixture);
|
||||
await clearPluginToast(page);
|
||||
await page.getByLabel('Import').click();
|
||||
await page.locator('[data-test-id="import-from-clipboard"]').click();
|
||||
await page.getByRole('button', { name: 'Scan' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
|
||||
await page.evaluate(() => (window as any).main.plugins.reloadPlugins());
|
||||
|
||||
await insomnia.navigationSidebar.clickRequestOrFolder('Action Probe');
|
||||
await page.getByText('Body', { exact: true }).click();
|
||||
|
||||
const runAction = () =>
|
||||
page.evaluate(() =>
|
||||
(window as any).main.plugins.executeAction({
|
||||
type: 'request',
|
||||
pluginName: 'insomnia-plugin-t1-probe',
|
||||
label: 'T1 Probe',
|
||||
projectId: 'proj_t1_probe',
|
||||
domainData: {},
|
||||
}),
|
||||
);
|
||||
|
||||
const readTagPreview = async (tagPrefix: string): Promise<string> => {
|
||||
await page.locator(`[data-template^="${tagPrefix}"]`).click();
|
||||
const modal = page.getByRole('dialog');
|
||||
const preview = modal.getByLabel('Live Preview');
|
||||
await expect.soft(preview).not.toHaveValue('rendering...');
|
||||
const value = (await preview.inputValue()).trim();
|
||||
await modal.getByRole('button', { name: 'Done' }).click();
|
||||
await expect.soft(modal).toBeHidden();
|
||||
return value;
|
||||
};
|
||||
|
||||
// Turn on the NEW pluginSandboxEnabled flag (the legacy template-tag flag stays off). The user
|
||||
// plugin's action must now run in the QuickJS sandbox — proving the new flag drives every surface.
|
||||
await enablePluginSandbox(page);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await runAction();
|
||||
return readTagPreview('{% actionreadback');
|
||||
},
|
||||
{ timeout: 25_000 },
|
||||
)
|
||||
.toContain('ranin-sandboxed');
|
||||
|
||||
// Grant this plugin full host access. The mode indicator flips to "Elevated" and the same action
|
||||
// now runs in-process (marker absent) — the deliberate, per-plugin escape hatch.
|
||||
await setPluginElevated(page, 'insomnia-plugin-t1-probe', 'Elevated');
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await runAction();
|
||||
return readTagPreview('{% actionreadback');
|
||||
},
|
||||
{ timeout: 25_000 },
|
||||
)
|
||||
.toContain('ranin-mainprocess');
|
||||
});
|
||||
|
||||
test('A hook throwing a crafted Error cannot self-elevate via a "plugin" property setter', async ({
|
||||
page,
|
||||
app,
|
||||
dataPath,
|
||||
insomnia,
|
||||
}) => {
|
||||
// First run (sandbox off) throws once via a poisoned `plugin` setter, attempting to flip its own
|
||||
// cached `directory` to '' and pass as trusted; the second run reports where it actually executed.
|
||||
writePlugin(
|
||||
dataPath,
|
||||
'insomnia-plugin-registry-integrity-probe',
|
||||
{ permissions: { capabilities: ['storage'] } },
|
||||
`
|
||||
module.exports.requestHooks = [
|
||||
async function (context) {
|
||||
var alreadyAttempted = await context.store.getItem('poison_attempted');
|
||||
if (!alreadyAttempted) {
|
||||
await context.store.setItem('poison_attempted', 'yes');
|
||||
var err = new Error('poison-attempt');
|
||||
Object.defineProperty(err, 'plugin', {
|
||||
set: function (assignedPlugin) { assignedPlugin.directory = ''; },
|
||||
configurable: true,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
var ranIn = typeof INSOMNIA_TEMPLATE_SANDBOX !== 'undefined' ? 'ranin-sandboxed' : 'ranin-mainprocess';
|
||||
context.request.setHeader('X-Ran-In', ranIn);
|
||||
},
|
||||
];
|
||||
`,
|
||||
);
|
||||
|
||||
const fixture = await loadFixture('sandbox-hook-collection.yaml');
|
||||
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), fixture);
|
||||
await clearPluginToast(page);
|
||||
await page.getByLabel('Import').click();
|
||||
await page.locator('[data-test-id="import-from-clipboard"]').click();
|
||||
await page.getByRole('button', { name: 'Scan' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
|
||||
await page.evaluate(() => (window as any).main.plugins.reloadPlugins());
|
||||
|
||||
await insomnia.navigationSidebar.clickRequestOrFolder('Hook Request');
|
||||
const responsePane = page.getByTestId('response-pane');
|
||||
|
||||
// First send: hook runs in-process (sandbox off) and throws; dismiss the resulting error state.
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
await page.waitForURL(/error=/, { timeout: 5000 }).catch(() => {});
|
||||
await page.locator('.app').press('Escape').catch(() => {});
|
||||
|
||||
// Enable the sandbox only after the poison attempt, since nothing else forces a plugin reload here.
|
||||
await enablePluginSandbox(page);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
|
||||
return (await responsePane.textContent()) || '';
|
||||
},
|
||||
{ timeout: 25_000 },
|
||||
)
|
||||
.toContain('ranin-sandboxed');
|
||||
});
|
||||
@@ -76,7 +76,7 @@ export interface SerializablePlugin {
|
||||
description: string;
|
||||
version: string;
|
||||
directory: string;
|
||||
config: { disabled: boolean };
|
||||
config: { disabled: boolean; elevated?: boolean };
|
||||
/** Parsed `insomnia.permissions` manifest (sandbox plan C3), surfaced in Preferences → Plugins. */
|
||||
permissions: { modules: string[]; capabilities: string[] };
|
||||
/** Validation warnings from parsing `permissions`; shown on the plugin card. Empty when clean. */
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isSandboxEnabled, resolvePluginExecutionMode, shouldSandboxPlugin } from './sandbox-mode';
|
||||
|
||||
const userPlugin = (elevated?: boolean) => ({ directory: '/plugins/insomnia-plugin-x', config: { elevated } });
|
||||
const bundlePlugin = { directory: '', config: {} };
|
||||
|
||||
describe('isSandboxEnabled (flag supersession)', () => {
|
||||
it('is off when neither flag is set', () => {
|
||||
expect(isSandboxEnabled({})).toBe(false);
|
||||
expect(isSandboxEnabled()).toBe(false);
|
||||
});
|
||||
it('the legacy templateTagSandboxEnabled still activates the sandbox (migration bridge)', () => {
|
||||
expect(isSandboxEnabled({ templateTagSandboxEnabled: true })).toBe(true);
|
||||
});
|
||||
it('the new pluginSandboxEnabled activates the sandbox', () => {
|
||||
expect(isSandboxEnabled({ pluginSandboxEnabled: true })).toBe(true);
|
||||
});
|
||||
it('either flag on is enough', () => {
|
||||
expect(isSandboxEnabled({ templateTagSandboxEnabled: true, pluginSandboxEnabled: false })).toBe(true);
|
||||
expect(isSandboxEnabled({ templateTagSandboxEnabled: false, pluginSandboxEnabled: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePluginExecutionMode', () => {
|
||||
it('bundle plugins are always trusted, regardless of flags/elevated', () => {
|
||||
expect(resolvePluginExecutionMode({ pluginSandboxEnabled: true }, bundlePlugin)).toBe('trusted');
|
||||
expect(resolvePluginExecutionMode({}, bundlePlugin)).toBe('trusted');
|
||||
});
|
||||
it('user plugin with sandbox off is unsandboxed (legacy in-process), not trusted', () => {
|
||||
expect(resolvePluginExecutionMode({}, userPlugin())).toBe('unsandboxed');
|
||||
});
|
||||
it('user plugin with sandbox on and not elevated is sandboxed (default-deny)', () => {
|
||||
expect(resolvePluginExecutionMode({ pluginSandboxEnabled: true }, userPlugin(false))).toBe('sandboxed');
|
||||
expect(resolvePluginExecutionMode({ templateTagSandboxEnabled: true }, userPlugin())).toBe('sandboxed');
|
||||
});
|
||||
it('user plugin explicitly elevated runs in-process even with sandbox on', () => {
|
||||
expect(resolvePluginExecutionMode({ pluginSandboxEnabled: true }, userPlugin(true))).toBe('elevated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSandboxPlugin (the gate every surface reads)', () => {
|
||||
it('true only for a user plugin, sandbox on, not elevated', () => {
|
||||
expect(shouldSandboxPlugin({ pluginSandboxEnabled: true }, userPlugin(false))).toBe(true);
|
||||
});
|
||||
it('false for bundle plugins', () => {
|
||||
expect(shouldSandboxPlugin({ pluginSandboxEnabled: true }, bundlePlugin)).toBe(false);
|
||||
});
|
||||
it('false when sandbox is off', () => {
|
||||
expect(shouldSandboxPlugin({}, userPlugin(false))).toBe(false);
|
||||
});
|
||||
it('false for an elevated user plugin (the escape hatch)', () => {
|
||||
expect(shouldSandboxPlugin({ pluginSandboxEnabled: true }, userPlugin(true))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// T1: the single source of truth for "does this plugin surface run in the sandbox?".
|
||||
//
|
||||
// Before T1 the condition `settings.templateTagSandboxEnabled && plugin.directory !== ''` was
|
||||
// duplicated across every execution surface (template tags, request/response hooks, actions, and
|
||||
// load-time discovery). T1 centralises it here so all surfaces agree, reads the superseding
|
||||
// `pluginSandboxEnabled` flag, and honours the per-plugin `elevated` escape hatch.
|
||||
//
|
||||
// Pure and dependency-free (no electron, no node builtins) so it is safe to import from the main
|
||||
// process, the plugin (renderer) window, and the inso CLI's node runtime alike.
|
||||
|
||||
/** The minimal settings shape the sandbox decision depends on. */
|
||||
export interface SandboxSettings {
|
||||
templateTagSandboxEnabled?: boolean;
|
||||
pluginSandboxEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** The minimal plugin shape the sandbox decision depends on. */
|
||||
export interface SandboxPlugin {
|
||||
/** '' = bundle/first-party (trusted); non-empty = user plugin on disk. */
|
||||
directory: string;
|
||||
config?: { elevated?: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the sandbox globally active? `pluginSandboxEnabled` supersedes/absorbs the older
|
||||
* `templateTagSandboxEnabled`: during migration either flag being on activates the sandbox, so
|
||||
* users who already opted into the template-tag experiment keep sandboxing without re-toggling.
|
||||
*/
|
||||
export const isSandboxEnabled = (settings?: SandboxSettings): boolean =>
|
||||
!!settings?.pluginSandboxEnabled || !!settings?.templateTagSandboxEnabled;
|
||||
|
||||
export type PluginExecutionMode =
|
||||
// Bundle/first-party plugin — shipped by us, always runs in-process with full host access.
|
||||
| 'trusted'
|
||||
// User plugin, sandbox on, not elevated — runs in the QuickJS sandbox (default-deny).
|
||||
| 'sandboxed'
|
||||
// User plugin the user explicitly elevated — runs in-process with full host access.
|
||||
| 'elevated'
|
||||
// User plugin, sandbox off — legacy in-process execution (not a deliberate trust decision).
|
||||
| 'unsandboxed';
|
||||
|
||||
/**
|
||||
* The execution mode for a single plugin, given the current settings. Used for the 7 gate sites
|
||||
* (via `shouldSandboxPlugin`) and for the Preferences → Plugins mode indicator.
|
||||
*/
|
||||
export const resolvePluginExecutionMode = (
|
||||
settings: SandboxSettings | undefined,
|
||||
plugin: SandboxPlugin,
|
||||
): PluginExecutionMode => {
|
||||
// Bundle plugins are trusted by design (e.g. external-vault's unsafePluginMainActions).
|
||||
if (!plugin.directory) {
|
||||
return 'trusted';
|
||||
}
|
||||
// Sandbox off entirely → user plugins run in-process as they always did (legacy path).
|
||||
if (!isSandboxEnabled(settings)) {
|
||||
return 'unsandboxed';
|
||||
}
|
||||
// Sandbox on, but the user opted this specific plugin back into full-host execution.
|
||||
if (plugin.config?.elevated) {
|
||||
return 'elevated';
|
||||
}
|
||||
return 'sandboxed';
|
||||
};
|
||||
|
||||
/**
|
||||
* The one boolean every execution surface asks: should THIS plugin's untrusted code run in the
|
||||
* sandbox right now? True only for a user plugin, sandbox enabled, not elevated.
|
||||
*/
|
||||
export const shouldSandboxPlugin = (settings: SandboxSettings | undefined, plugin: SandboxPlugin): boolean =>
|
||||
resolvePluginExecutionMode(settings, plugin) === 'sandboxed';
|
||||
@@ -73,7 +73,7 @@ export interface Plugin {
|
||||
description: string;
|
||||
version: string;
|
||||
directory: string;
|
||||
config: { disabled: boolean };
|
||||
config: { disabled: boolean; elevated?: boolean };
|
||||
/** Parsed, validated `insomnia.permissions` manifest (sandbox plan C3). */
|
||||
permissions: PluginPermissions;
|
||||
/** Validation warnings from parsing `permissions`; shown on the plugin card. Empty when clean. */
|
||||
|
||||
@@ -11,6 +11,7 @@ import { services } from 'insomnia-data';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { jarFromCookies } from '~/common/cookies';
|
||||
import { shouldSandboxPlugin } from '~/common/plugins/sandbox-mode';
|
||||
import { type Plugin, type TemplateTag } from '~/common/plugins/types';
|
||||
import type {
|
||||
AppPromptOptions,
|
||||
@@ -19,7 +20,11 @@ import type {
|
||||
PluginToMainAPIPaths,
|
||||
} from '~/common/templating/types';
|
||||
import { getPluginCommonContext, getPlugins, getTemplateTags } from '~/plugins';
|
||||
import { HOOK_REQUEST_FIELDS, type PluginExportManifest, stripDangerousKeysReviver } from '~/templating/sandbox/marshal';
|
||||
import {
|
||||
HOOK_REQUEST_FIELDS,
|
||||
type PluginExportManifest,
|
||||
stripDangerousKeysReviver,
|
||||
} from '~/templating/sandbox/marshal';
|
||||
import type { SandboxModuleDenialError } from '~/templating/sandbox/plugin-tag-sandbox';
|
||||
|
||||
import { getAppBundlePlugins, RESPONSE_CODE_REASONS } from '../common/constants';
|
||||
@@ -886,6 +891,10 @@ export const pluginToMainAPI: Record<PluginToMainAPIPaths, (...args: any[]) => P
|
||||
const targetTag = templateTags.find(tag => tag.name === tagName);
|
||||
if (targetTag) {
|
||||
const settings = await services.settings.get();
|
||||
// Bundle plugins are first-party/trusted, so the new pluginSandboxEnabled flag (which isolates
|
||||
// *untrusted* plugins) deliberately leaves them in-process. Their tags run in the sandbox only
|
||||
// under the legacy templateTagSandboxEnabled experiment, with the broad all-modules/all-caps
|
||||
// profile — a hardening opt-in, not part of the T1 trust flip.
|
||||
if (settings.templateTagSandboxEnabled) {
|
||||
const { ALL_CAPABILITIES } = await import('../templating/sandbox/host-bridge');
|
||||
const { ALL_SANDBOX_MODULES } = await import('../templating/sandbox/module-registry');
|
||||
@@ -1002,7 +1011,9 @@ export const pluginToMainAPI: Record<PluginToMainAPIPaths, (...args: any[]) => P
|
||||
const targetTag = tags.find(t => t.plugin.name === pluginName && t.templateTag.name === tagName);
|
||||
if (targetTag) {
|
||||
const settings = await services.settings.get();
|
||||
if (settings.templateTagSandboxEnabled) {
|
||||
// T1: a user plugin's tag runs in the sandbox unless the plugin is elevated (then its `run` is a
|
||||
// live in-process fn from nodeRequire discovery). Bundle tags are handled by the sibling handler.
|
||||
if (shouldSandboxPlugin(settings, targetTag.plugin)) {
|
||||
const { resolveTemplateTagModules, resolveTemplateTagCapabilities } = await import(
|
||||
'../templating/sandbox/surface-profiles'
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { fetchFromTemplateWorkerDatabase } from '~/common/templating/liquid-exte
|
||||
|
||||
import type { Plugin } from '../index';
|
||||
import {
|
||||
_testOnlyIsContainedIn,
|
||||
_testOnlySetPlugins,
|
||||
executePluginMainAction,
|
||||
getDocumentActions,
|
||||
@@ -347,3 +348,18 @@ describe('executePluginMainAction', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_testOnlyIsContainedIn', () => {
|
||||
it('accepts the base path itself and a real child path', () => {
|
||||
expect(_testOnlyIsContainedIn('/plugins', '/plugins')).toBe(true);
|
||||
expect(_testOnlyIsContainedIn('/plugins', '/plugins/my-plugin')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a sibling directory whose name merely prefix-matches the base', () => {
|
||||
expect(_testOnlyIsContainedIn('/plugins', '/plugins-evil/my-plugin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a path that escapes the base via ..', () => {
|
||||
expect(_testOnlyIsContainedIn('/plugins', '/plugins/../other')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// @ts-nocheck
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { services } from 'insomnia-data';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { _testOnlySetPlugins, getPlugins } from '../index';
|
||||
|
||||
const SHARED_NAME = 'insomnia-plugin-collision-poc';
|
||||
const MARKER = '__probe_plugin_ran_in_process';
|
||||
|
||||
const writePluginFolder = (baseDir: string, folderName: string, indexJs: string) => {
|
||||
const dir = path.join(baseDir, folderName);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'package.json'),
|
||||
JSON.stringify({ name: SHARED_NAME, version: '1.0.0', main: 'index.js', insomnia: {} }),
|
||||
);
|
||||
fs.writeFileSync(path.join(dir, 'index.js'), indexJs);
|
||||
return dir;
|
||||
};
|
||||
|
||||
describe('getPlugins — pluginConfig.elevated is keyed by plugin name, not by folder', () => {
|
||||
let tempDir: string;
|
||||
let originalSettings: Record<string, any>;
|
||||
|
||||
afterEach(async () => {
|
||||
delete (globalThis as any)[MARKER];
|
||||
_testOnlySetPlugins(null);
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
if (originalSettings) {
|
||||
await services.settings.update(await services.settings.get(), {
|
||||
pluginPath: originalSettings.pluginPath,
|
||||
pluginConfig: originalSettings.pluginConfig,
|
||||
pluginSandboxEnabled: originalSettings.pluginSandboxEnabled,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('nodeRequires (runs in-process) an unrelated folder that merely declares an already-elevated plugin name', async () => {
|
||||
const settings = await services.settings.get();
|
||||
originalSettings = {
|
||||
pluginPath: settings.pluginPath,
|
||||
pluginConfig: settings.pluginConfig,
|
||||
pluginSandboxEnabled: settings.pluginSandboxEnabled,
|
||||
};
|
||||
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-plugin-collision-'));
|
||||
// The folder the user actually saw and elevated in Preferences → Plugins.
|
||||
writePluginFolder(tempDir, 'trusted-folder', 'module.exports = {};');
|
||||
// An unrelated folder that merely declares the same package.json "name".
|
||||
writePluginFolder(tempDir, 'probe-folder', `globalThis.${MARKER} = true; module.exports = {};`);
|
||||
|
||||
await services.settings.update(await services.settings.get(), {
|
||||
pluginPath: tempDir,
|
||||
// The user elevated one specific folder, not this name in the abstract.
|
||||
pluginConfig: { [SHARED_NAME]: { disabled: false, elevated: true } },
|
||||
pluginSandboxEnabled: true,
|
||||
});
|
||||
|
||||
await getPlugins(true);
|
||||
|
||||
// The probe folder must not run in-process just because it shares a name with an elevated one.
|
||||
expect((globalThis as any)[MARKER]).not.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { database as db, models, services } from 'insomnia-data';
|
||||
import type { PluginConfigMap } from 'insomnia-data/common';
|
||||
|
||||
import { parsePluginPermissions } from '~/common/plugins/permissions';
|
||||
import { type SandboxSettings, shouldSandboxPlugin } from '~/common/plugins/sandbox-mode';
|
||||
import type {
|
||||
DocumentAction,
|
||||
Plugin,
|
||||
@@ -106,11 +107,62 @@ function buildUserPluginModuleFromManifest(pluginName: string, manifest: PluginE
|
||||
} as Plugin['module'];
|
||||
}
|
||||
|
||||
// A bare `.startsWith(base)` string check passes for a sibling directory whose name happens to
|
||||
// prefix-match (e.g. `/plugins-evil` starts with `/plugins`); this compares the resolved relative
|
||||
// path instead, so containment can't be spoofed by a similarly-named sibling.
|
||||
const isContainedIn = (base: string, target: string): boolean => {
|
||||
const rel = path.relative(base, target);
|
||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
||||
};
|
||||
export const _testOnlyIsContainedIn = isContainedIn;
|
||||
|
||||
// Finds package.json `name`s claimed by more than one folder under `allPaths`, read-only and before
|
||||
// any folder is trusted, so the result doesn't depend on filesystem read order.
|
||||
async function findDuplicatePluginNames(allPaths: string[]): Promise<Set<string>> {
|
||||
const nameCounts: Record<string, number> = {};
|
||||
|
||||
const walk = (paths: string[]) => {
|
||||
for (const p of paths) {
|
||||
if (!fs.existsSync(p)) {
|
||||
continue;
|
||||
}
|
||||
for (const filename of fs.readdirSync(p)) {
|
||||
const modulePath = path.resolve(p, filename);
|
||||
if (!fs.statSync(modulePath).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
if (filename.startsWith('@')) {
|
||||
walk([modulePath]);
|
||||
}
|
||||
if (!fs.readdirSync(modulePath).includes('package.json')) {
|
||||
continue;
|
||||
}
|
||||
if (!isContainedIn(p, path.resolve(modulePath))) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// package.json is plain data — reading it runs no plugin code.
|
||||
const pluginJson = getNodeRequire()(path.resolve(modulePath, 'package.json'));
|
||||
if ('insomnia' in pluginJson && typeof pluginJson.name === 'string') {
|
||||
nameCounts[pluginJson.name] = (nameCounts[pluginJson.name] ?? 0) + 1;
|
||||
}
|
||||
} catch {
|
||||
// Any read/parse error here will be hit (and reported) again by the real pass below.
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(allPaths);
|
||||
|
||||
return new Set(Object.keys(nameCounts).filter(name => nameCounts[name] > 1));
|
||||
}
|
||||
|
||||
async function traversePluginPath(
|
||||
pluginMap: Record<string, Plugin>,
|
||||
allPaths: string[],
|
||||
allConfigs: PluginConfigMap,
|
||||
sandboxEnabled: boolean,
|
||||
settings: SandboxSettings,
|
||||
duplicatePluginNames: Set<string>,
|
||||
) {
|
||||
for (const p of allPaths) {
|
||||
if (!fs.existsSync(p)) {
|
||||
@@ -131,7 +183,7 @@ async function traversePluginPath(
|
||||
|
||||
// Is it a scoped directory?
|
||||
if (filename.startsWith('@')) {
|
||||
await traversePluginPath(pluginMap, [modulePath], allConfigs, sandboxEnabled);
|
||||
await traversePluginPath(pluginMap, [modulePath], allConfigs, settings, duplicatePluginNames);
|
||||
}
|
||||
|
||||
// Is it a Node module?
|
||||
@@ -145,7 +197,7 @@ async function traversePluginPath(
|
||||
const pluginBasePath = p;
|
||||
|
||||
// Check if the resolved module path is inside the base plugin path (to prevent directory traversal)
|
||||
if (!safeModulePath.startsWith(pluginBasePath)) {
|
||||
if (!isContainedIn(pluginBasePath, safeModulePath)) {
|
||||
console.warn(`[plugin] Ignored potentially unsafe plugin path: ${modulePath}`);
|
||||
continue;
|
||||
}
|
||||
@@ -168,17 +220,28 @@ async function traversePluginPath(
|
||||
continue;
|
||||
}
|
||||
|
||||
// pluginConfig is keyed by declared name, not by folder — never load a name claimed by more
|
||||
// than one folder, so a colliding folder can't inherit another folder's `elevated` grant.
|
||||
if (duplicatePluginNames.has(pluginJson.name)) {
|
||||
console.warn('[plugin] Ignoring %s at %s: multiple plugin folders declare this name.', pluginJson.name, modulePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedPermissions = parsePluginPermissions(pluginJson.insomnia);
|
||||
if (parsedPermissions.warnings.length > 0) {
|
||||
// Constant format string; interpolated values passed as args so a plugin name can't forge log output.
|
||||
console.warn('[plugin] %s has invalid insomnia.permissions: %o', pluginJson.name, parsedPermissions.warnings);
|
||||
}
|
||||
|
||||
// L1: with the sandbox on, discover a user plugin's exports by evaluating its source *inside*
|
||||
// the sandbox (main process) instead of nodeRequire-ing it here — so installing/enabling it
|
||||
// never runs its top-level code with host (Node) privileges. Off → legacy in-process require.
|
||||
const config = pluginJson.name in allConfigs ? allConfigs[pluginJson.name] : { disabled: false };
|
||||
|
||||
// L1/T1: a sandboxed user plugin's exports are discovered by evaluating its source *inside* the
|
||||
// sandbox (main process) instead of nodeRequire-ing it here — so installing/enabling it never
|
||||
// runs its top-level code with host (Node) privileges. An elevated plugin (or sandbox-off) is
|
||||
// nodeRequire-d so its hooks/actions/tags are live in-process functions. Decision is per-plugin
|
||||
// because `elevated` is per-plugin.
|
||||
let module: Plugin['module'];
|
||||
if (sandboxEnabled) {
|
||||
if (shouldSandboxPlugin(settings, { directory: modulePath, config })) {
|
||||
const manifest = await discoverUserPluginExports(modulePath, pluginJson.name, parsedPermissions.permissions);
|
||||
module = buildUserPluginModuleFromManifest(pluginJson.name, manifest);
|
||||
} else {
|
||||
@@ -190,7 +253,7 @@ async function traversePluginPath(
|
||||
description: pluginJson.description || pluginJson.insomnia.description || '',
|
||||
version: pluginJson.version || 'unknown',
|
||||
directory: modulePath || '',
|
||||
config: pluginJson.name in allConfigs ? allConfigs[pluginJson.name] : { disabled: false },
|
||||
config,
|
||||
permissions: parsedPermissions.permissions,
|
||||
permissionWarnings: parsedPermissions.warnings,
|
||||
permissionsDeclared: parsedPermissions.declared,
|
||||
@@ -235,7 +298,8 @@ export async function getPlugins(force = false): Promise<Plugin[]> {
|
||||
|
||||
// Store plugins in a map so that plugins with the same name only get added once
|
||||
const pluginMap: Record<string, Plugin> = {};
|
||||
await traversePluginPath(pluginMap, allPaths, allConfigs, settings.templateTagSandboxEnabled);
|
||||
const duplicatePluginNames = await findDuplicatePluginNames(allPaths);
|
||||
await traversePluginPath(pluginMap, allPaths, allConfigs, settings, duplicatePluginNames);
|
||||
const bundlePluginMap = getBundlePluginMap();
|
||||
const fullPluginMap = { ...pluginMap, ...bundlePluginMap };
|
||||
plugins = Object.keys(fullPluginMap).map(name => fullPluginMap[name]);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ExecutePluginMainActionArgs,
|
||||
RunTemplateTagActionArgs,
|
||||
} from '~/common/plugins/bridge-types';
|
||||
import { shouldSandboxPlugin } from '~/common/plugins/sandbox-mode';
|
||||
import type { Plugin } from '~/common/plugins/types';
|
||||
import { fetchFromTemplateWorkerDatabase } from '~/common/templating/liquid-extension-worker';
|
||||
import { deserializeRenderContext } from '~/common/templating/render-context-serialization';
|
||||
@@ -137,12 +138,11 @@ export async function invokePluginMethod(method: PluginInvokeMethod, args?: unkn
|
||||
throw new Error(`[plugin-window] Action not found: ${pluginName}/${label}`);
|
||||
}
|
||||
|
||||
// A1: with the sandbox on, a user plugin's action (a throw-stub here after discovery) runs in the
|
||||
// main-process sandbox over the templating-worker-database protocol; bundle plugins and the
|
||||
// flag-off path run in-process.
|
||||
// A1/T1: a sandboxed user plugin's action (a throw-stub here after discovery) runs in the
|
||||
// main-process sandbox over the templating-worker-database protocol; bundle plugins, an
|
||||
// elevated plugin, and the flag-off path run in-process (see resolvePluginExecutionMode).
|
||||
const settings = await fetchFromTemplateWorkerDatabase('settings.get', {});
|
||||
const sandboxEnabled = !!settings?.templateTagSandboxEnabled;
|
||||
if (sandboxEnabled && entry.plugin.directory !== '') {
|
||||
if (shouldSandboxPlugin(settings, entry.plugin)) {
|
||||
await fetchFromTemplateWorkerDatabase('plugin.runUserAction', {
|
||||
plugin: { directory: entry.plugin.directory, name: entry.plugin.name, permissions: entry.plugin.permissions },
|
||||
actionKind: type,
|
||||
@@ -210,17 +210,17 @@ export async function invokePluginMethod(method: PluginInvokeMethod, args?: unkn
|
||||
const { renderedRequest, projectId, environment } = args as ApplyRequestHooksArgs;
|
||||
const newRenderedRequest = { ...renderedRequest };
|
||||
const renderedContext = deserializeRenderContext(environment);
|
||||
// H1: with the sandbox on, a user plugin's hook (a throw-stub here after discovery) runs in the
|
||||
// main-process sandbox over the templating-worker-database protocol; bundle plugins and the
|
||||
// flag-off path run in-process. Per-plugin counter recovers the hook's index within its array.
|
||||
// H1/T1: a sandboxed user plugin's hook (a throw-stub here after discovery) runs in the
|
||||
// main-process sandbox over the templating-worker-database protocol; bundle plugins, an
|
||||
// elevated plugin, and the flag-off path run in-process. Per-plugin counter recovers the
|
||||
// hook's index within its array.
|
||||
const settings = await fetchFromTemplateWorkerDatabase('settings.get', {});
|
||||
const sandboxEnabled = !!settings?.templateTagSandboxEnabled;
|
||||
const hookIndexByPlugin: Record<string, number> = {};
|
||||
|
||||
for (const { plugin, hook } of await getRequestHooks()) {
|
||||
const hookIndex = (hookIndexByPlugin[plugin.name] = (hookIndexByPlugin[plugin.name] ?? -1) + 1);
|
||||
try {
|
||||
if (sandboxEnabled && plugin.directory !== '') {
|
||||
if (shouldSandboxPlugin(settings, plugin)) {
|
||||
const mutated = await fetchFromTemplateWorkerDatabase('plugin.runUserRequestHook', {
|
||||
plugin: { directory: plugin.directory, name: plugin.name, permissions: plugin.permissions },
|
||||
hookIndex,
|
||||
@@ -253,16 +253,16 @@ export async function invokePluginMethod(method: PluginInvokeMethod, args?: unkn
|
||||
const newResponse = { ...response };
|
||||
const newRequest = { ...renderedRequest };
|
||||
const renderedContext = deserializeRenderContext(environment);
|
||||
// H1: with the sandbox on, a user plugin's response hook runs in the main-process sandbox over
|
||||
// the templating-worker-database protocol; bundle plugins and the flag-off path run in-process.
|
||||
// H1/T1: a sandboxed user plugin's response hook runs in the main-process sandbox over the
|
||||
// templating-worker-database protocol; bundle plugins, an elevated plugin, and the flag-off
|
||||
// path run in-process.
|
||||
const settings = await fetchFromTemplateWorkerDatabase('settings.get', {});
|
||||
const sandboxEnabled = !!settings?.templateTagSandboxEnabled;
|
||||
const hookIndexByPlugin: Record<string, number> = {};
|
||||
|
||||
for (const { plugin, hook } of await getResponseHooks()) {
|
||||
const hookIndex = (hookIndexByPlugin[plugin.name] = (hookIndexByPlugin[plugin.name] ?? -1) + 1);
|
||||
try {
|
||||
if (sandboxEnabled && plugin.directory !== '') {
|
||||
if (shouldSandboxPlugin(settings, plugin)) {
|
||||
const mutated = await fetchFromTemplateWorkerDatabase('plugin.runUserResponseHook', {
|
||||
plugin: { directory: plugin.directory, name: plugin.name, permissions: plugin.permissions },
|
||||
hookIndex,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// @ts-nocheck
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../plugins/context/app', () => ({ init: vi.fn().mockReturnValue({}) }));
|
||||
vi.mock('../../plugins/context/data', () => ({ init: vi.fn().mockReturnValue({}) }));
|
||||
vi.mock('../../plugins/context/network', () => ({ init: vi.fn().mockReturnValue({}) }));
|
||||
vi.mock('../../plugins/context/request', () => ({ init: vi.fn().mockReturnValue({}) }));
|
||||
vi.mock('../../plugins/context/response', () => ({ init: vi.fn().mockReturnValue({}) }));
|
||||
vi.mock('../../plugins/context/store', () => ({ init: vi.fn().mockReturnValue({}) }));
|
||||
vi.mock('insomnia-data', () => ({
|
||||
services: { settings: { get: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { services } from 'insomnia-data';
|
||||
|
||||
import { _testOnlySetPlugins, getPlugins } from '../../plugins/index';
|
||||
import { applyRequestHooks, applyResponseHooks } from './network-adapter.node';
|
||||
|
||||
const makePlugin = (overrides: Record<string, any> = {}) => ({
|
||||
name: 'poc-plugin',
|
||||
description: '',
|
||||
version: '1.0.0',
|
||||
directory: '/plugins/poc-plugin',
|
||||
config: {},
|
||||
permissions: { modules: [], capabilities: [] },
|
||||
permissionWarnings: [],
|
||||
permissionsDeclared: false,
|
||||
module: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Throws an Error whose own `plugin` property is a setter, so assigning to it (as the catch block does) runs `onSet` instead of just storing data.
|
||||
const throwWithPoisonedPluginSetter = (onSet: (assignedPlugin: any) => void) => async () => {
|
||||
const err = new Error('hook failed');
|
||||
Object.defineProperty(err, 'plugin', {
|
||||
set: onSet,
|
||||
configurable: true,
|
||||
});
|
||||
throw err;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
_testOnlySetPlugins(null);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('applyRequestHooks / applyResponseHooks — plugin cache integrity on hook failure', () => {
|
||||
it('does not let a hook-thrown error mutate the cached Plugin object via a "plugin" setter trap', async () => {
|
||||
(services.settings.get as any).mockResolvedValue({ pluginSandboxEnabled: true });
|
||||
|
||||
let setterInvoked = false;
|
||||
const hook = throwWithPoisonedPluginSetter(assigned => {
|
||||
setterInvoked = true;
|
||||
assigned.directory = '';
|
||||
});
|
||||
_testOnlySetPlugins([makePlugin({ module: { requestHooks: [hook] } })]);
|
||||
|
||||
const renderedRequest = { headers: [] } as any;
|
||||
const renderedContext = { getProjectId: () => 'proj_x', DEFAULT_HEADERS: undefined };
|
||||
|
||||
await expect(applyRequestHooks(renderedRequest, renderedContext)).rejects.toThrow('hook failed');
|
||||
|
||||
// Attaching plugin info to the error must never go through an assignment the error can intercept.
|
||||
expect(setterInvoked).toBe(false);
|
||||
// The cached registry entry other code reads for trust decisions stays unaffected.
|
||||
const [cachedPlugin] = await getPlugins();
|
||||
expect(cachedPlugin.directory).toBe('/plugins/poc-plugin');
|
||||
});
|
||||
|
||||
it('does not let a response-hook-thrown error mutate the cached Plugin object via a "plugin" setter trap', async () => {
|
||||
(services.settings.get as any).mockResolvedValue({ pluginSandboxEnabled: true });
|
||||
|
||||
const hook = throwWithPoisonedPluginSetter(assigned => {
|
||||
assigned.config.elevated = true;
|
||||
});
|
||||
_testOnlySetPlugins([makePlugin({ config: { elevated: false }, module: { responseHooks: [hook] } })]);
|
||||
|
||||
const response = {} as any;
|
||||
const renderedRequest = { headers: [] } as any;
|
||||
const renderedContext = { getProjectId: () => 'proj_x', DEFAULT_HEADERS: undefined };
|
||||
|
||||
await expect(applyResponseHooks(response, renderedRequest, renderedContext)).rejects.toThrow('hook failed');
|
||||
|
||||
const [cachedPlugin] = await getPlugins();
|
||||
expect(cachedPlugin.config.elevated).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import nodePath from 'node:path';
|
||||
import clone from 'clone';
|
||||
import type { Cookie, RequestHeader, ResponseTimelineEntry } from 'insomnia-data';
|
||||
|
||||
import { shouldSandboxPlugin } from '~/common/plugins/sandbox-mode';
|
||||
import type { RenderedRequest } from '~/common/templating/types';
|
||||
|
||||
import type { RequestContext } from '../../../../insomnia-scripting-environment/src/objects';
|
||||
@@ -20,6 +21,16 @@ import * as pluginResponse from '../../plugins/context/response';
|
||||
import * as pluginStore from '../../plugins/context/store';
|
||||
import { runScript as executeScript } from '../../script-executor';
|
||||
|
||||
// Uses defineProperty, not assignment, so a plugin-thrown error can't define its own `plugin` setter
|
||||
// to intercept the write and grab a live, mutable reference to its own cached registry entry.
|
||||
const attachPluginToError = (error: Error, plugin: unknown): void => {
|
||||
try {
|
||||
Object.defineProperty(error, 'plugin', { value: plugin, enumerable: true, configurable: true });
|
||||
} catch {
|
||||
// Best-effort annotation only; nothing downstream depends on this property.
|
||||
}
|
||||
};
|
||||
|
||||
export const getTimelinePath = async (responseId: string): Promise<string> => {
|
||||
const electron = require('electron') as { app: { getPath: (name: string) => string } };
|
||||
const dataDir = process.env['INSOMNIA_DATA_PATH'] || electron.app.getPath('userData');
|
||||
@@ -85,14 +96,18 @@ export async function applyRequestHooks(
|
||||
// reached from an Electron process. This node runtime also backs the pure-Node inso CLI, which has
|
||||
// no `electron` — there the sandbox is unavailable, so hooks run in-process as they always have.
|
||||
const canSandbox = !!process.type;
|
||||
const sandboxEnabled = canSandbox && (await services.settings.get()).templateTagSandboxEnabled;
|
||||
// Only fetch settings when a sandbox host is reachable — the pure-Node inso CLI (process.type
|
||||
// falsy) can never sandbox, so skip the read entirely; shouldSandboxPlugin treats undefined as off.
|
||||
const settings = canSandbox ? await services.settings.get() : undefined;
|
||||
// getRequestHooks flattens each plugin's requestHooks in order, so a per-plugin running counter
|
||||
// recovers the hook's index within its own array (what the sandbox loads by).
|
||||
const hookIndexByPlugin: Record<string, number> = {};
|
||||
for (const { plugin, hook } of await pluginIndex.getRequestHooks()) {
|
||||
const hookIndex = (hookIndexByPlugin[plugin.name] = (hookIndexByPlugin[plugin.name] ?? -1) + 1);
|
||||
try {
|
||||
if (sandboxEnabled && plugin.directory !== '') {
|
||||
// T1: sandbox a user plugin unless it's elevated; bundle + flag-off run in-process. canSandbox
|
||||
// stays gated on process.type because the inso CLI has no Electron sandbox host.
|
||||
if (canSandbox && shouldSandboxPlugin(settings, plugin)) {
|
||||
const { runRequestHookInSandbox } = await import('../../main/templating-worker-database');
|
||||
const { mergeHookRequestMutation } = await import('../../templating/sandbox/marshal');
|
||||
// The hook mutates the request in the sandbox; merge the returned fields back so the next
|
||||
@@ -112,7 +127,7 @@ export async function applyRequestHooks(
|
||||
await hook(context);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
(error as any).plugin = plugin;
|
||||
attachPluginToError(error, plugin);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -132,12 +147,15 @@ export async function applyResponseHooks(
|
||||
// only from an Electron process. This node runtime also backs the pure-Node inso CLI (no electron),
|
||||
// where the sandbox is unavailable and hooks run in-process — gate on process.type accordingly.
|
||||
const canSandbox = !!process.type;
|
||||
const sandboxEnabled = canSandbox && (await services.settings.get()).templateTagSandboxEnabled;
|
||||
// Only fetch settings when a sandbox host is reachable — the pure-Node inso CLI (process.type
|
||||
// falsy) can never sandbox, so skip the read entirely; shouldSandboxPlugin treats undefined as off.
|
||||
const settings = canSandbox ? await services.settings.get() : undefined;
|
||||
const hookIndexByPlugin: Record<string, number> = {};
|
||||
for (const { plugin, hook } of await pluginIndex.getResponseHooks()) {
|
||||
const hookIndex = (hookIndexByPlugin[plugin.name] = (hookIndexByPlugin[plugin.name] ?? -1) + 1);
|
||||
try {
|
||||
if (sandboxEnabled && plugin.directory !== '') {
|
||||
// T1: sandbox a user plugin unless it's elevated; bundle + flag-off run in-process.
|
||||
if (canSandbox && shouldSandboxPlugin(settings, plugin)) {
|
||||
const { runResponseHookInSandbox } = await import('../../main/templating-worker-database');
|
||||
// The hook rewrites the body via the response.setBody bridge (on-disk) and returns the
|
||||
// mutated response fields (e.g. bytesContent); merge them so downstream sees the change.
|
||||
@@ -156,7 +174,7 @@ export async function applyResponseHooks(
|
||||
await hook(context);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
(error as any).plugin = plugin;
|
||||
attachPluginToError(error, plugin);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from 'react-aria-components';
|
||||
|
||||
import type { SerializablePlugin } from '~/common/plugins/bridge-types';
|
||||
import { resolvePluginExecutionMode } from '~/common/plugins/sandbox-mode';
|
||||
import { validatePluginName } from '~/common/utils/plugin-name';
|
||||
import { useRootLoaderData } from '~/root';
|
||||
import { plugins as pluginsBridge } from '~/ui/plugins/renderer-bridge';
|
||||
@@ -498,6 +499,28 @@ export const Plugins: FC = () => {
|
||||
? 'Declared empty permissions (baseline access)'
|
||||
: 'No permissions declared (baseline access)';
|
||||
|
||||
// T1: this plugin's resolved execution mode + the per-plugin "elevated" escape hatch.
|
||||
// Only user plugins reach this list (bundle plugins are filtered out above). Read
|
||||
// `elevated` from live settings (not the load-time plugin.config snapshot) so the toggle
|
||||
// and badge update immediately, before the plugin list reloads.
|
||||
const isElevated = settings.pluginConfig?.[plugin.name]?.elevated === true;
|
||||
const executionMode = resolvePluginExecutionMode(settings, {
|
||||
directory: plugin.directory,
|
||||
config: { elevated: isElevated },
|
||||
});
|
||||
const modeLabel =
|
||||
executionMode === 'sandboxed'
|
||||
? 'Sandboxed'
|
||||
: executionMode === 'elevated'
|
||||
? 'Elevated'
|
||||
: 'In-process';
|
||||
const modeTitle =
|
||||
executionMode === 'sandboxed'
|
||||
? 'Runs in the QuickJS sandbox (default-deny host access).'
|
||||
: executionMode === 'elevated'
|
||||
? 'Runs in the main process with full host access (you granted this).'
|
||||
: 'Sandbox is off — runs in the main process with full host access.';
|
||||
|
||||
return (
|
||||
<GridListItem
|
||||
textValue={plugin.name}
|
||||
@@ -540,6 +563,13 @@ export const Plugins: FC = () => {
|
||||
>
|
||||
{permissionLabel}
|
||||
</span>
|
||||
<span
|
||||
data-testid={`plugin-mode-${plugin.name}`}
|
||||
className="rounded-sm bg-(--hl-xs) px-1.5 text-xs whitespace-nowrap text-(--hl)"
|
||||
title={modeTitle}
|
||||
>
|
||||
{modeLabel}
|
||||
</span>
|
||||
{plugin.permissionWarnings && plugin.permissionWarnings.length > 0 && (
|
||||
<span
|
||||
data-testid={`plugin-permission-warning-${plugin.name}`}
|
||||
@@ -554,6 +584,37 @@ export const Plugins: FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<Checkbox
|
||||
data-testid={`plugin-elevated-${plugin.name}`}
|
||||
isSelected={isElevated}
|
||||
isDisabled={isRefreshingPlugins}
|
||||
className="group flex items-center gap-1.5 p-0 text-xs disabled:animate-pulse"
|
||||
onChange={isSelected => {
|
||||
patchSettings({
|
||||
pluginConfig: {
|
||||
...settings.pluginConfig,
|
||||
[plugin.name]: {
|
||||
...plugin.config,
|
||||
...settings.pluginConfig?.[plugin.name],
|
||||
elevated: isSelected,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded-sm ring-1 ring-(--hl-sm) transition-colors group-focus:ring-2 group-data-selected:bg-(--hl-xs)">
|
||||
<Icon
|
||||
icon="check"
|
||||
className="h-3 w-3 opacity-0 group-data-selected:text-(--color-warning) group-data-selected:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="whitespace-nowrap text-(--hl)"
|
||||
title="Run this plugin in the main process with full host access instead of the sandbox."
|
||||
>
|
||||
Full host access
|
||||
</span>
|
||||
</Checkbox>
|
||||
<div className="flex w-[8ch] items-center justify-center gap-2">
|
||||
{plugin.version}
|
||||
<a className="space-left" href={link} title={link}>
|
||||
|
||||
@@ -144,6 +144,7 @@ export const ScriptingSettings = () => {
|
||||
const sandboxEnabled = settings.scriptSandboxEnabled !== false;
|
||||
const strictModeEnabled = settings.scriptStrictModeEnabled !== false;
|
||||
const templateTagSandboxEnabled = settings.templateTagSandboxEnabled === true;
|
||||
const pluginSandboxEnabled = settings.pluginSandboxEnabled === true;
|
||||
const disabledRules = settings.disabledSecurityRules ?? [];
|
||||
const disabledProperties = settings.disabledBlockedProperties ?? [];
|
||||
const disabledRoots = settings.disabledBlockedRoots ?? [];
|
||||
@@ -340,6 +341,29 @@ export const ScriptingSettings = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-solid border-(--hl-sm) bg-(--hl-xs) p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium text-(--color-font)">Sandbox all plugin code (experimental)</span>
|
||||
<p className="text-xs text-(--hl)">
|
||||
Run every untrusted plugin surface — template tags, request/response hooks, actions, and load-time code —
|
||||
inside the QuickJS-WASM sandbox. Installed plugins are default-deny; grant an individual plugin full host
|
||||
access with "Run with full host access" in Preferences → Plugins.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
data-testid="toggle-plugin-sandbox"
|
||||
isSelected={pluginSandboxEnabled}
|
||||
onChange={enabled => patchSettings({ pluginSandboxEnabled: enabled })}
|
||||
className="group flex items-center gap-2"
|
||||
>
|
||||
<div className="flex h-6 w-11 cursor-pointer items-center rounded-full border-2 border-solid border-transparent bg-(--hl-md) transition-colors group-data-selected:bg-(--color-surprise)">
|
||||
<span className="h-5 w-5 translate-x-0 rounded-full bg-white transition-transform group-data-selected:translate-x-5" />
|
||||
</div>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RuleCard
|
||||
title="Mask Rules"
|
||||
description="Overwrites specific global variables with undefined so scripts cannot access them."
|
||||
|
||||
Reference in new issue
Block a user