From e759bbceaf43d0dced880041de9618b0192dec32 Mon Sep 17 00:00:00 2001 From: Jack Kavanagh Date: Wed, 5 Aug 2026 16:02:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(templating):=20(T1)=20flip=20trust=20model?= =?UTF-8?q?=20=E2=80=94=20pluginSandboxEnabled=20+=20per-plugin=20elevated?= =?UTF-8?q?=20opt-in=20(#10318)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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-) and a "Full host access" checkbox (data-testid plugin-elevated-) that writes pluginConfig..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 --- packages/insomnia-data/common-src/settings.ts | 10 +- packages/insomnia-data/src/models/settings.ts | 1 + .../tests/smoke/sandbox-template-tags.test.ts | 194 +++++++++++++++++- .../src/common/plugins/bridge-types.ts | 2 +- .../src/common/plugins/sandbox-mode.test.ts | 55 +++++ .../src/common/plugins/sandbox-mode.ts | 70 +++++++ packages/insomnia/src/common/plugins/types.ts | 2 +- .../src/main/templating-worker-database.ts | 15 +- .../src/plugins/__tests__/index.test.ts | 16 ++ ...ugin-name-collision-elevated-trust.test.ts | 70 +++++++ packages/insomnia/src/plugins/index.ts | 82 +++++++- .../insomnia/src/plugins/invoke-method.ts | 28 +-- .../network/network-adapter.node.test.ts | 87 ++++++++ .../runtimes/network/network-adapter.node.ts | 30 ++- .../src/ui/components/settings/plugins.tsx | 61 ++++++ .../settings/scripting-settings.tsx | 24 +++ 16 files changed, 711 insertions(+), 36 deletions(-) create mode 100644 packages/insomnia/src/common/plugins/sandbox-mode.test.ts create mode 100644 packages/insomnia/src/common/plugins/sandbox-mode.ts create mode 100644 packages/insomnia/src/plugins/__tests__/plugin-name-collision-elevated-trust.test.ts create mode 100644 packages/insomnia/src/runtimes/network/network-adapter.node.test.ts diff --git a/packages/insomnia-data/common-src/settings.ts b/packages/insomnia-data/common-src/settings.ts index 1fe62b8f28..7c26a74f71 100644 --- a/packages/insomnia-data/common-src/settings.ts +++ b/packages/insomnia-data/common-src/settings.ts @@ -95,9 +95,12 @@ export type SettingsOfType = 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; +export type PluginConfigMap = Record; 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. diff --git a/packages/insomnia-data/src/models/settings.ts b/packages/insomnia-data/src/models/settings.ts index f598be1cb8..3debd9c316 100644 --- a/packages/insomnia-data/src/models/settings.ts +++ b/packages/insomnia-data/src/models/settings.ts @@ -85,6 +85,7 @@ export function init(): BaseSettings { scriptSandboxEnabled: true, scriptStrictModeEnabled: true, templateTagSandboxEnabled: false, + pluginSandboxEnabled: false, disabledSecurityRules: [], disabledBlockedProperties: [], disabledBlockedRoots: [], diff --git a/packages/insomnia-smoke-test/tests/smoke/sandbox-template-tags.test.ts b/packages/insomnia-smoke-test/tests/smoke/sandbox-template-tags.test.ts index 257f9085ea..f097f99e1d 100644 --- a/packages/insomnia-smoke-test/tests/smoke/sandbox-template-tags.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/sandbox-template-tags.test.ts @@ -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 => { + 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'); +}); diff --git a/packages/insomnia/src/common/plugins/bridge-types.ts b/packages/insomnia/src/common/plugins/bridge-types.ts index 05fc474f2b..3c5f8e04fc 100644 --- a/packages/insomnia/src/common/plugins/bridge-types.ts +++ b/packages/insomnia/src/common/plugins/bridge-types.ts @@ -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. */ diff --git a/packages/insomnia/src/common/plugins/sandbox-mode.test.ts b/packages/insomnia/src/common/plugins/sandbox-mode.test.ts new file mode 100644 index 0000000000..5efa570444 --- /dev/null +++ b/packages/insomnia/src/common/plugins/sandbox-mode.test.ts @@ -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); + }); +}); diff --git a/packages/insomnia/src/common/plugins/sandbox-mode.ts b/packages/insomnia/src/common/plugins/sandbox-mode.ts new file mode 100644 index 0000000000..136a067abf --- /dev/null +++ b/packages/insomnia/src/common/plugins/sandbox-mode.ts @@ -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'; diff --git a/packages/insomnia/src/common/plugins/types.ts b/packages/insomnia/src/common/plugins/types.ts index e916102563..b9c1daee77 100644 --- a/packages/insomnia/src/common/plugins/types.ts +++ b/packages/insomnia/src/common/plugins/types.ts @@ -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. */ diff --git a/packages/insomnia/src/main/templating-worker-database.ts b/packages/insomnia/src/main/templating-worker-database.ts index 0f48408248..659b51fbd9 100644 --- a/packages/insomnia/src/main/templating-worker-database.ts +++ b/packages/insomnia/src/main/templating-worker-database.ts @@ -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 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 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' ); diff --git a/packages/insomnia/src/plugins/__tests__/index.test.ts b/packages/insomnia/src/plugins/__tests__/index.test.ts index f912b28321..1ba1b71fe0 100644 --- a/packages/insomnia/src/plugins/__tests__/index.test.ts +++ b/packages/insomnia/src/plugins/__tests__/index.test.ts @@ -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); + }); +}); diff --git a/packages/insomnia/src/plugins/__tests__/plugin-name-collision-elevated-trust.test.ts b/packages/insomnia/src/plugins/__tests__/plugin-name-collision-elevated-trust.test.ts new file mode 100644 index 0000000000..e6493cfd32 --- /dev/null +++ b/packages/insomnia/src/plugins/__tests__/plugin-name-collision-elevated-trust.test.ts @@ -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; + + 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); + }); +}); diff --git a/packages/insomnia/src/plugins/index.ts b/packages/insomnia/src/plugins/index.ts index d05820c23e..d9673d6bf6 100644 --- a/packages/insomnia/src/plugins/index.ts +++ b/packages/insomnia/src/plugins/index.ts @@ -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> { + const nameCounts: Record = {}; + + 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, allPaths: string[], allConfigs: PluginConfigMap, - sandboxEnabled: boolean, + settings: SandboxSettings, + duplicatePluginNames: Set, ) { 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 { // Store plugins in a map so that plugins with the same name only get added once const pluginMap: Record = {}; - 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]); diff --git a/packages/insomnia/src/plugins/invoke-method.ts b/packages/insomnia/src/plugins/invoke-method.ts index 45f8a9fb3a..2891b98cc1 100644 --- a/packages/insomnia/src/plugins/invoke-method.ts +++ b/packages/insomnia/src/plugins/invoke-method.ts @@ -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 = {}; 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 = {}; 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, diff --git a/packages/insomnia/src/runtimes/network/network-adapter.node.test.ts b/packages/insomnia/src/runtimes/network/network-adapter.node.test.ts new file mode 100644 index 0000000000..6b6738968d --- /dev/null +++ b/packages/insomnia/src/runtimes/network/network-adapter.node.test.ts @@ -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 = {}) => ({ + 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); + }); +}); diff --git a/packages/insomnia/src/runtimes/network/network-adapter.node.ts b/packages/insomnia/src/runtimes/network/network-adapter.node.ts index 8ca1353bc2..aa3a346127 100644 --- a/packages/insomnia/src/runtimes/network/network-adapter.node.ts +++ b/packages/insomnia/src/runtimes/network/network-adapter.node.ts @@ -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 => { 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 = {}; 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 = {}; 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; } } diff --git a/packages/insomnia/src/ui/components/settings/plugins.tsx b/packages/insomnia/src/ui/components/settings/plugins.tsx index 914ebaa31b..8c44de2394 100644 --- a/packages/insomnia/src/ui/components/settings/plugins.tsx +++ b/packages/insomnia/src/ui/components/settings/plugins.tsx @@ -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 ( { > {permissionLabel} + + {modeLabel} + {plugin.permissionWarnings && plugin.permissionWarnings.length > 0 && ( {
+ { + patchSettings({ + pluginConfig: { + ...settings.pluginConfig, + [plugin.name]: { + ...plugin.config, + ...settings.pluginConfig?.[plugin.name], + elevated: isSelected, + }, + }, + }); + }} + > +
+ +
+ + Full host access + +
{plugin.version} diff --git a/packages/insomnia/src/ui/components/settings/scripting-settings.tsx b/packages/insomnia/src/ui/components/settings/scripting-settings.tsx index 413b288246..a1075f5ee1 100644 --- a/packages/insomnia/src/ui/components/settings/scripting-settings.tsx +++ b/packages/insomnia/src/ui/components/settings/scripting-settings.tsx @@ -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 = () => {
+
+
+
+ Sandbox all plugin code (experimental) +

+ 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. +

+
+ patchSettings({ pluginSandboxEnabled: enabled })} + className="group flex items-center gap-2" + > +
+ +
+
+
+
+