diff --git a/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts b/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts index 4881f96758f1..2f019807a31a 100644 --- a/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts @@ -1,7 +1,7 @@ import { Buffer } from 'node:buffer'; import path from 'node:path'; -import { expect } from '@playwright/test'; +import { expect, type Page } from '@playwright/test'; import { getFixturePath, loadFixture } from '../../playwright/paths'; import { test } from '../../playwright/test'; @@ -682,6 +682,19 @@ test.describe('unhappy paths', () => { }); }); +// Race the expected pre-toggle error text against a 200 OK response. Abort immediately on failure instead of waiting for the error locator to time out. +async function expectBlockedBeforeOk(page: Page, errorText: string) { + const errorLocator = page.getByTestId('response-pane').getByText(errorText); + const okLocator = page.locator('[data-testid="response-status-tag"]:visible', { hasText: '200 OK' }); + const testResponseCode = await Promise.race([ + errorLocator.waitFor({ state: 'visible' }).then(() => 'blocked' as const), + okLocator.waitFor({ state: 'visible' }).then(() => 'ok' as const), + ]); + if (testResponseCode !== 'blocked') { + throw new Error(`expected the script to be blocked with "${errorText}", but a 200 OK response arrived first`); + } +} + test.describe('sandbox features', () => { test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms'); @@ -709,11 +722,7 @@ test.describe('sandbox features', () => { await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); - - // verify blocked-root error - await expect - .soft(page.getByTestId('response-pane')) - .toContainText("The script was blocked because it used 'this'."); + await expectBlockedBeforeOk(page, "The script was blocked because it used 'this'."); // navigate to Settings → Scripting, disable the "Scopes" blocked roots group await page.getByTestId('settings-button').click(); @@ -746,11 +755,7 @@ test.describe('sandbox features', () => { await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); - - // verify blocked-property error - await expect - .soft(page.getByTestId('response-pane')) - .toContainText("The script was blocked because it used the property 'prototype'."); + await expectBlockedBeforeOk(page, "The script was blocked because it used the property 'prototype'."); // navigate to Settings → Scripting, disable the "Prototype Mutation" blocked properties group await page.getByTestId('settings-button').click(); @@ -786,8 +791,7 @@ test.describe('sandbox features', () => { // send — Function masked to undefined → V8 uses the identifier name: "Function is not a constructor" await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); - - await expect.soft(page.getByTestId('response-pane')).toContainText('Function is not a constructor'); + await expectBlockedBeforeOk(page, 'Function is not a constructor'); // navigate to Settings → Scripting, disable the "Runtime APIs" mask group await page.getByTestId('settings-button').click(); @@ -817,11 +821,7 @@ test.describe('sandbox features', () => { await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); - - // verify blocked-root error - await expect - .soft(page.getByTestId('response-pane')) - .toContainText("The script was blocked because it used 'process'."); + await expectBlockedBeforeOk(page, "The script was blocked because it used 'process'."); // navigate to Settings → Scripting, disable only the "Node.js Internals" BLOCKED ROOTS group. await page.getByTestId('settings-button').click(); @@ -842,4 +842,237 @@ test.describe('sandbox features', () => { .not.toContainText("The script was blocked because it used 'process'."); await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK'); }); + + // Master sandbox toggle: turning it off disables all static checks. + test("master 'Enable script sandbox' toggle", async ({ page }) => { + await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter'); + + await page.getByRole('tab', { name: 'Scripts' }).click(); + const editor = page.getByTestId('CodeEditor').getByRole('textbox'); + await editor.fill(`insomnia.environment.set('result', String(this?.x));`); + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expectBlockedBeforeOk(page, "The script was blocked because it used 'this'."); + + await page.getByTestId('settings-button').click(); + await page.locator('text=Insomnia Preferences').first().click(); + await page.getByRole('tab', { name: 'Scripting' }).click(); + + const masterSwitch = page.locator( + 'xpath=//span[normalize-space(text())="Enable script sandbox"]/ancestor::div[contains(@class,"justify-between")][1]//label[@data-react-aria-pressable]', + ); + await masterSwitch.scrollIntoViewIfNeeded(); + await masterSwitch.click(); + await expect.soft(masterSwitch).not.toHaveAttribute('data-selected'); + + // child toggles become disabled when the master is off + const strictSwitch = page.locator( + 'xpath=//span[normalize-space(text())="use strict"]/ancestor::div[contains(@class,"justify-between")][1]//label[@data-react-aria-pressable]', + ); + await expect.soft(strictSwitch).toHaveAttribute('data-disabled'); + + await page.locator('.app').press('Escape'); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expect + .soft(page.getByTestId('response-pane')) + .not.toContainText("The script was blocked because it used 'this'."); + await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK'); + }); + + // 'use strict' toggle: strict mode causes assignment to undeclared identifier to throw. + test("'use strict' toggle", async ({ page }) => { + await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter'); + + await page.getByRole('tab', { name: 'Scripts' }).click(); + const editor = page.getByTestId('CodeEditor').getByRole('textbox'); + await editor.fill(`function f(){ undeclared = 1; return undeclared; } insomnia.environment.set('result', String(f()));`); + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expectBlockedBeforeOk(page, 'undeclared is not defined'); + + await page.getByTestId('settings-button').click(); + await page.locator('text=Insomnia Preferences').first().click(); + await page.getByRole('tab', { name: 'Scripting' }).click(); + const strictSwitch = page.locator( + 'xpath=//span[normalize-space(text())="use strict"]/ancestor::div[contains(@class,"justify-between")][1]//label[@data-react-aria-pressable]', + ); + await strictSwitch.scrollIntoViewIfNeeded(); + await strictSwitch.click(); + await expect.soft(strictSwitch).not.toHaveAttribute('data-selected'); + await page.locator('.app').press('Escape'); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expect.soft(page.getByTestId('response-pane')).not.toContainText('undeclared is not defined'); + await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK'); + }); + + // 'block unresolvable properties' toggle: dynamic computed access is statically blocked. + test("'block unresolvable properties' toggle", async ({ page }) => { + await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter'); + + await page.getByRole('tab', { name: 'Scripts' }).click(); + const editor = page.getByTestId('CodeEditor').getByRole('textbox'); + await editor.fill(`const k = 'foo'; const o = { foo: 42 }; insomnia.environment.set('result', String(o[k]));`); + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expectBlockedBeforeOk(page, 'dynamic computed property access that cannot be statically verified'); + + await page.getByTestId('settings-button').click(); + await page.locator('text=Insomnia Preferences').first().click(); + await page.getByRole('tab', { name: 'Scripting' }).click(); + const blockUnresolvableSwitch = page.locator( + 'xpath=//span[normalize-space(text())="block unresolvable properties"]/ancestor::div[contains(@class,"justify-between")][1]//label[@data-react-aria-pressable]', + ); + await blockUnresolvableSwitch.scrollIntoViewIfNeeded(); + await blockUnresolvableSwitch.click(); + await expect.soft(blockUnresolvableSwitch).not.toHaveAttribute('data-selected'); + await page.locator('.app').press('Escape'); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expect + .soft(page.getByTestId('response-pane')) + .not.toContainText('dynamic computed property access that cannot be statically verified'); + await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK'); + }); + + // Mask Rules / Async Scheduling group: setImmediate is masked to undefined at runtime. + test('Mask Rules / Async Scheduling group', async ({ page }) => { + await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter'); + + await page.getByRole('tab', { name: 'Scripts' }).click(); + const editor = page.getByTestId('CodeEditor').getByRole('textbox'); + await editor.fill(`setImmediate(() => {}); insomnia.environment.set('result', 'ok');`); + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expectBlockedBeforeOk(page, 'setImmediate is not a function'); + + await page.getByTestId('settings-button').click(); + await page.locator('text=Insomnia Preferences').first().click(); + await page.getByRole('tab', { name: 'Scripting' }).click(); + const asyncSchedulingSwitch = page.locator( + 'div:has(> h4:has-text("Async Scheduling")) label[data-react-aria-pressable]', + ); + await asyncSchedulingSwitch.scrollIntoViewIfNeeded(); + await asyncSchedulingSwitch.click(); + await expect.soft(asyncSchedulingSwitch).not.toHaveAttribute('data-selected'); + await page.locator('.app').press('Escape'); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expect.soft(page.getByTestId('response-pane')).not.toContainText('setImmediate is not a function'); + await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK'); + }); + + // Blocked Properties / Stack Inspection group: 'captureStackTrace' is blocked. + test('Blocked Properties / Stack Inspection group', async ({ page }) => { + await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter'); + + await page.getByRole('tab', { name: 'Scripts' }).click(); + const editor = page.getByTestId('CodeEditor').getByRole('textbox'); + await editor.fill(`insomnia.environment.set('result', String(typeof Error.captureStackTrace));`); + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expectBlockedBeforeOk(page, "The script was blocked because it used the property 'captureStackTrace'."); + + await page.getByTestId('settings-button').click(); + await page.locator('text=Insomnia Preferences').first().click(); + await page.getByRole('tab', { name: 'Scripting' }).click(); + const stackInspectionSwitch = page.locator( + 'div:has(> h4:has-text("Stack Inspection")) label[data-react-aria-pressable]', + ); + await stackInspectionSwitch.scrollIntoViewIfNeeded(); + await stackInspectionSwitch.click(); + await expect.soft(stackInspectionSwitch).not.toHaveAttribute('data-selected'); + await page.locator('.app').press('Escape'); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expect + .soft(page.getByTestId('response-pane')) + .not.toContainText("The script was blocked because it used the property 'captureStackTrace'."); + await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK'); + }); + + // Blocked Properties / Accessor Helpers group: 'defineProperty' is blocked. + test('Blocked Properties / Accessor Helpers group', async ({ page }) => { + await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter'); + + await page.getByRole('tab', { name: 'Scripts' }).click(); + const editor = page.getByTestId('CodeEditor').getByRole('textbox'); + await editor.fill(`const o = {}; Object.defineProperty(o, 'a', { value: 1 }); insomnia.environment.set('result', String(o.a));`); + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expectBlockedBeforeOk(page, "The script was blocked because it used the property 'defineProperty'."); + + await page.getByTestId('settings-button').click(); + await page.locator('text=Insomnia Preferences').first().click(); + await page.getByRole('tab', { name: 'Scripting' }).click(); + const accessorHelpersSwitch = page.locator( + 'div:has(> h4:has-text("Accessor Helpers")) label[data-react-aria-pressable]', + ); + await accessorHelpersSwitch.scrollIntoViewIfNeeded(); + await accessorHelpersSwitch.click(); + await expect.soft(accessorHelpersSwitch).not.toHaveAttribute('data-selected'); + await page.locator('.app').press('Escape'); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expect + .soft(page.getByTestId('response-pane')) + .not.toContainText("The script was blocked because it used the property 'defineProperty'."); + await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK'); + }); + + // Blocked Roots / Global Object Aliases group: 'globalThis' is blocked. + test('Blocked Roots / Global Object Aliases group', async ({ page }) => { + await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter'); + + await page.getByRole('tab', { name: 'Scripts' }).click(); + const editor = page.getByTestId('CodeEditor').getByRole('textbox'); + await editor.fill(`insomnia.environment.set('result', String(globalThis.Object));`); + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expectBlockedBeforeOk(page, "The script was blocked because it used 'globalThis'."); + + await page.getByTestId('settings-button').click(); + await page.locator('text=Insomnia Preferences').first().click(); + await page.getByRole('tab', { name: 'Scripting' }).click(); + const globalAliasesSwitch = page.locator( + 'div:has(> h4:has-text("Global Object Aliases")) label[data-react-aria-pressable]', + ); + await globalAliasesSwitch.scrollIntoViewIfNeeded(); + await globalAliasesSwitch.click(); + await expect.soft(globalAliasesSwitch).not.toHaveAttribute('data-selected'); + await page.locator('.app').press('Escape'); + + // Static rule passes, but the mask still resolves `globalThis` to undefined, + // so `globalThis.Object` throws — verify we now hit the next security layer. + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expect + .soft(page.getByTestId('response-pane')) + .toContainText("Cannot read properties of undefined (reading 'Object')"); + + // Disable the matching mask group so `globalThis` resolves to the real host global. + await page.getByTestId('settings-button').click(); + await page.locator('text=Insomnia Preferences').first().click(); + await page.getByRole('tab', { name: 'Scripting' }).click(); + const globalMaskSwitch = page.locator( + 'div:has(> h4:has-text("Global & Node.js Internals")) label[data-react-aria-pressable]', + ); + await globalMaskSwitch.scrollIntoViewIfNeeded(); + await globalMaskSwitch.click(); + await expect.soft(globalMaskSwitch).not.toHaveAttribute('data-selected'); + await page.locator('.app').press('Escape'); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expect + .soft(page.getByTestId('response-pane')) + .not.toContainText("Cannot read properties of undefined (reading 'Object')"); + await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK'); + }); }); diff --git a/packages/insomnia/src/common/settings.ts b/packages/insomnia/src/common/settings.ts index 6ded268f7016..877b9fcb4cdd 100644 --- a/packages/insomnia/src/common/settings.ts +++ b/packages/insomnia/src/common/settings.ts @@ -163,16 +163,15 @@ export interface Settings { saveVaultKeyToOSSecretManager: boolean; vaultSecretCacheDuration: number; dataFolders: string[]; - // AST and shadowing check. - scriptSandboxEnabled: boolean; - // Wraps the user script in 'use strict', preventing accidental globals and making `this` undefined. - scriptStrictModeEnabled: boolean; - // Names of security rules that have been individually disabled. - disabledSecurityRules: string[]; - // AST blocked-property names that have been individually disabled. - disabledBlockedProperties: string[]; - // AST blocked-root names that have been individually disabled. - disabledBlockedRoots: string[]; + + // pre/post scripting sandbox related settings + scriptSandboxEnabled: boolean; + scriptStrictModeEnabled: boolean; + scriptBlockUnresolvableProperties: boolean; + disabledSecurityRules: string[]; + disabledBlockedProperties: string[]; + disabledBlockedRoots: string[]; + /** Custom npm registry URL for plugin installation (e.g., corporate mirror). Empty string uses the default https://registry.npmjs.org/. */ npmRegistryUrl: string; } diff --git a/packages/insomnia/src/insomnia-data/src/models/settings.ts b/packages/insomnia/src/insomnia-data/src/models/settings.ts index 16e51994623e..d8b01cdb14bf 100644 --- a/packages/insomnia/src/insomnia-data/src/models/settings.ts +++ b/packages/insomnia/src/insomnia-data/src/models/settings.ts @@ -79,6 +79,7 @@ export function init(): BaseSettings { dataFolders: [], scriptSandboxEnabled: true, scriptStrictModeEnabled: true, + scriptBlockUnresolvableProperties: true, disabledSecurityRules: [], disabledBlockedProperties: [], disabledBlockedRoots: [], diff --git a/packages/insomnia/src/scripting/__tests__/sandbox.test.ts b/packages/insomnia/src/scripting/__tests__/sandbox.test.ts index dd72129cdeb5..cfac7ec21ab6 100644 --- a/packages/insomnia/src/scripting/__tests__/sandbox.test.ts +++ b/packages/insomnia/src/scripting/__tests__/sandbox.test.ts @@ -18,15 +18,14 @@ const withoutProperty = (name: string) => const withoutRoot = (name: string) => new Set([...ALL_BLOCKED_ROOTS].filter(r => r !== name)); -// --------------------------------------------------------------------------- -// Blocked properties — one canonical script per rule covering both dot and -// bracket notation where applicable. The unblocking section below mirrors -// each rule to confirm the disable path works too. -// --------------------------------------------------------------------------- +// Passes blockDynamic=false — mirrors disabling scriptBlockUnresolvableProperties in settings. +const checkNoDynamic = (script: string) => + () => checkSandboxViolations(script, ALL_BLOCKED_PROPERTIES, ALL_BLOCKED_ROOTS, false); describe('checkSandboxViolations', () => { - describe('blocked properties — dot notation', () => { + // Blocked property names accessed via dot notation (obj.constructor, etc.) + describe('properties: dot', () => { it('blocks prototype', () => blocked('Promise.prototype.then')); it('blocks mainModule', () => blocked('proc.mainModule')); it('blocks constructor', () => blocked('obj.constructor')); @@ -47,7 +46,8 @@ describe('checkSandboxViolations', () => { it('blocks getOwnPropertyDescriptors', () => blocked('Object.getOwnPropertyDescriptors(obj)')); }); - describe('blocked properties — bracket notation', () => { + // Same property names but via static-string bracket access (obj["constructor"]) + describe('properties: bracket', () => { it('blocks constructor', () => blocked('obj["constructor"]')); it('blocks __proto__', () => blocked('obj["__proto__"]')); it('blocks prototype', () => blocked('Promise["prototype"]')); @@ -56,11 +56,8 @@ describe('checkSandboxViolations', () => { it('blocks defineProperty', () => blocked('Object["defineProperty"](obj, "key", desc)')); }); - // --------------------------------------------------------------------------- - // Blocked roots - // --------------------------------------------------------------------------- - - describe('blocked roots — direct member access', () => { + // Blocked global roots accessed directly via member expressions + describe('roots: member', () => { it('blocks this', () => blocked('this.x')); it('blocks globalThis', () => blocked('globalThis.require')); it('blocks global', () => blocked('global.require')); @@ -74,23 +71,28 @@ describe('checkSandboxViolations', () => { it('blocks arguments', () => blocked('arguments[0]')); }); - describe('blocked roots — direct call', () => { + // Direct invocation of a blocked root identifier — caught by the CallExpression visitor + describe('roots: call', () => { it('blocks constructor called directly', () => blocked('constructor("return process")()')); + + it('blocks globalThis called directly', () => + blocked('globalThis()')); + + it('blocks aliased constructor call', () => + blocked('const c = constructor; c("return process")()')); }); - describe('blocked roots — bracket notation', () => { + // Blocked roots reached via bracket access — root traversal still matches + describe('roots: bracket', () => { it('blocks globalThis["require"]', () => blocked('globalThis["require"]()')); it('blocks window["process"]', () => blocked('window["process"]')); it('blocks self["require"]', () => blocked('self["require"]')); it('blocks process["env"]', () => blocked('process["env"]')); }); - // --------------------------------------------------------------------------- - // Alias chains and destructuring - // --------------------------------------------------------------------------- - - describe('this — alias chains and destructuring', () => { + // ThisExpression handling: dynamic keys, const/let aliasing, and destructuring forms + describe('this: aliases & destructuring', () => { it('blocks this.process.mainModule.require via member', () => blocked(`this.process.mainModule.require('child_process')`)); @@ -113,7 +115,8 @@ describe('checkSandboxViolations', () => { blocked(`({ process } = this)`)); }); - describe('globalThis — alias chains and destructuring', () => { + // Alias-tracking for globalThis: covers Logical/Conditional/Sequence init forms and transitive aliases + describe('globalThis: aliases & destructuring', () => { it('blocks const alias: const g = globalThis; g.require', () => blocked(`const g = globalThis; g.require('child_process')`)); @@ -122,13 +125,34 @@ describe('checkSandboxViolations', () => { it('blocks destructuring assignment from globalThis', () => blocked(`({ require } = globalThis)`)); - }); - // --------------------------------------------------------------------------- - // Prototype chain mutation - // --------------------------------------------------------------------------- + it('blocks renamed destructuring from globalThis', () => + blocked(`const { require: r } = globalThis`)); + + it('blocks alias init via LogicalExpression', () => + blocked(`const g = null || globalThis; g.require('child_process')`)); + + it('blocks alias init via ConditionalExpression', () => + blocked(`const g = cond ? globalThis : null; g.require('child_process')`)); + + it('blocks alias init via SequenceExpression', () => + blocked(`const g = (0, globalThis); g.require('child_process')`)); + + it('blocks transitive alias (alias of alias)', () => + blocked(`const a = globalThis; const b = a; b.require('child_process')`)); + + it('blocks assignment alias via LogicalExpression', () => + blocked(`let g; g = false || globalThis; g.require('child_process')`)); + + it('blocks computed property on aliased root', () => + blocked(`const g = globalThis; g["require"]`)); + + it('blocks blocked root used deep inside an assignment RHS', () => + blocked(`a.b.c = globalThis.process`)); + }); - describe('prototype chain mutation', () => { + // Reads and writes against built-in .prototype objects must be rejected + describe('prototype mutation', () => { it('blocks Promise.prototype.then mutation', () => blocked(`Promise.prototype.then = function(fn) { fn.call(globalThis); }`)); @@ -148,10 +172,7 @@ describe('checkSandboxViolations', () => { blocked(`Promise['prototype']`)); }); - // --------------------------------------------------------------------------- - // Dynamic import - // --------------------------------------------------------------------------- - + // Static `import` declarations and dynamic `import()` are both rejected at the AST level describe('import', () => { it('blocks dynamic import()', () => blocked(`import('child_process')`)); @@ -166,20 +187,17 @@ describe('checkSandboxViolations', () => { blocked(`import { readFile } from 'fs'`)); }); - // --------------------------------------------------------------------------- - // Symbol.species - // --------------------------------------------------------------------------- - + // Symbol.species would let user code subvert built-in subclassing — always blocked describe('Symbol.species', () => { it('blocks Symbol.species', () => blocked(`Symbol.species`)); - }); - // --------------------------------------------------------------------------- - // Unblocking — disabling a rule must allow previously blocked scripts - // --------------------------------------------------------------------------- + it('blocks Symbol["species"]', () => + blocked(`Symbol['species']`)); + }); - describe('unblocking — disabling a blocked property rule allows the script', () => { + // Verify each blocked property rule can be individually disabled by settings + describe('unblocking: properties', () => { const cases: [name: string, script: string][] = [ ['prototype', 'Promise.prototype.then'], ['mainModule', 'proc.mainModule'], @@ -207,7 +225,8 @@ describe('checkSandboxViolations', () => { } }); - describe('unblocking — disabling a blocked root rule allows the script', () => { + // Verify each blocked root rule can be individually disabled and that alias tracking follows + describe('unblocking: roots', () => { const cases: [name: string, script: string][] = [ ['this', 'this.x'], ['globalThis', 'globalThis.require'], @@ -235,10 +254,34 @@ describe('checkSandboxViolations', () => { expect(check('const g = globalThis; g.require', ALL_BLOCKED_PROPERTIES, withoutRoot('globalThis'))).not.toThrow()); }); - // --------------------------------------------------------------------------- - // Allowed scripts - // --------------------------------------------------------------------------- + // Fail-closed policy: any computed key that cannot be resolved at parse time is rejected + describe('dynamic computed keys', () => { + it('blocks concatenated string key: obj["con"+"structor"]', () => + blocked(`obj["con"+"structor"]`)); + it('blocks variable key: const k = "constructor"; obj[k]', () => + blocked(`const k = "constructor"; obj[k]`)); + + it('blocks unverifiable computed key: obj[someExpr]', () => + blocked(`obj[someExpr]`)); + + it('blocks template literal with expressions: obj[`${x}`]', () => + blocked('obj[`${x}`]')); + }); + + // Static-but-obfuscated keys (template literals, concatenation) must still resolve and block + describe('blocked properties via computed access', () => { + it('blocks constructor via template literal: obj[`constructor`]', () => + blocked('obj[`constructor`]')); + + it('blocks constructor via concatenation: obj["con"+"structor"]', () => + blocked('obj["con"+"structor"]')); + + it('blocks AsyncFunction constructor via template: (async()=>{})[`constructor`]', () => + blocked('(async()=>{})[`constructor`]')); + }); + + // Generic JS patterns and common pm/insomnia usage that must continue to pass describe('allowed scripts', () => { it('allows normal variable declarations', () => allowed(`const x = 1 + 2`)); @@ -261,7 +304,140 @@ describe('checkSandboxViolations', () => { it('allows console.log', () => allowed(`console.log('hello')`)); - it('allows class with prototype-like property name in string', () => - allowed(`const key = 'prototype'; obj[key]`)); + it('allows safe property access via string literal', () => + allowed(`obj['foo']`)); + + it('allows safe property access via dot notation', () => + allowed(`obj.foo`)); + + it('allows arrow functions and closures', () => + allowed(`const add = (a, b) => a + b; add(1, 2)`)); + + it('allows class declarations and instantiation', () => + allowed(`class Foo { greet() { return 'hi' } } new Foo().greet()`)); + + it('allows spread and rest', () => + allowed(`const a = [1, 2]; const b = [...a, 3]; const fn = (...args) => args.length`)); + + it('allows optional chaining on user objects', () => + allowed(`const x = obj?.foo?.bar`)); + + it('allows nullish coalescing', () => + allowed(`const x = a ?? b`)); + + it('allows destructuring user objects and arrays', () => + allowed(`const { a, b } = data; const [first] = list`)); + + it('allows template literals', () => + allowed('const s = `hello ${name}`')); + + it('allows try/catch/finally', () => + allowed(`try { doThing() } catch (e) { console.log(e) } finally { cleanup() }`)); + + it('allows for-of loops', () => + allowed(`for (const item of items) { console.log(item) }`)); + + it('allows JSON.parse / JSON.stringify', () => + allowed(`JSON.parse(JSON.stringify({ a: 1 }))`)); + + it('allows Promise chains', () => + allowed(`Promise.resolve(1).then(v => v + 1)`)); + + it('allows Math / Date / Number / String built-ins', () => + allowed(`Math.max(1, Date.now()); Number('3'); String(1)`)); + + it('allows Array methods', () => + allowed(`[1, 2, 3].map(x => x * 2).filter(x => x > 1)`)); + + it('allows regex literals', () => + allowed(`const re = /foo/g; 'foobar'.match(re)`)); + + it('allows common pm patterns', () => + allowed(`pm.environment.set('k', 'v'); pm.variables.get('k')`)); + + it('allows common insomnia patterns', () => + allowed(`insomnia.request.headers.add({ key: 'X', value: '1' })`)); + }); + + // With dynamic-key blocking disabled, only statically resolvable blocked names still throw + describe('blockDynamic=false toggle', () => { + it('allows concatenated string key when block-dynamic is off', () => + expect(checkNoDynamic('obj["con"+"structor"]')).not.toThrow()); + + it('allows identifier variable key when block-dynamic is off', () => + expect(checkNoDynamic('obj[someVar]')).not.toThrow()); + + it('allows template literal with expression when block-dynamic is off', () => + expect(checkNoDynamic('obj[`${x}`]')).not.toThrow()); + + it('still blocks a static string literal with a blocked name even when block-dynamic is off', () => + expect(checkNoDynamic('obj["constructor"]')).toThrow()); + + it('still blocks a no-expression template literal with a blocked name even when block-dynamic is off', () => + expect(checkNoDynamic('obj[`constructor`]')).toThrow()); + }); + + // These scripts intentionally pass AST checks; runtime masking in maskRules catches them. + describe('function-wrapper indirection (AST-pass, runtime-masked)', () => { + it('module indirection via function wrapper', () => { + allowed(` + const getModule = function() { return module; }; + const m = getModule(); + m.require('child_process'); + `); + }); + + it('self indirection via function wrapper', () => { + allowed(` + const getSelf = function() { return self; }; + const w = getSelf(); + w.require('child_process'); + `); + }); + + it('exports indirection via function wrapper', () => { + allowed(` + const getExports = function() { return exports; }; + const e = getExports(); + e.foo = 'bar'; + `); + }); + + it('Buffer indirection via function wrapper', () => { + allowed(` + const getBuf = function() { return Buffer; }; + const b = getBuf(); + b.allocUnsafe(256); + `); + }); + + it('frames indirection via function wrapper', () => { + allowed(` + const getFrames = function() { return frames; }; + const f = getFrames(); + f[0]; + `); + }); + }); + + // Demonstrates the trade-off when block-dynamic is disabled: AsyncFunction reachable via concat + describe('AsyncFunction constructor via computed access', () => { + it('blocks AsyncFunction constructor with blockDynamic=true (default)', () => { + expect(() => checkSandboxViolations( + `(async () => {})['con' + 'structor']`, + ALL_BLOCKED_PROPERTIES, + ALL_BLOCKED_ROOTS, + true + )).toThrow(); + }); + + it('allows AsyncFunction constructor access when blockDynamic=false', () => { + expect(() => checkSandboxViolations( + `(async () => {})['con' + 'structor']`, + ALL_BLOCKED_PROPERTIES, + ALL_BLOCKED_ROOTS, + false + )).not.toThrow(); + }); }); }); diff --git a/packages/insomnia/src/scripting/require-interceptor.ts b/packages/insomnia/src/scripting/require-interceptor.ts index 76e102939640..9b765103034b 100644 --- a/packages/insomnia/src/scripting/require-interceptor.ts +++ b/packages/insomnia/src/scripting/require-interceptor.ts @@ -41,19 +41,21 @@ export const requireInterceptor = (moduleName: string): any => { // Block setImmediate return blockMethods(require('node:timers'), ['setImmediate'], 'timers'); } else if (moduleName === 'buffer') { - // Block unsafe allocation methods to prevent heap memory disclosure. - // Buffer.allocUnsafe(n) / Buffer.allocUnsafeSlow(n) return a buffer backed by uninitialized memory. + // Buffer.allocUnsafe / Buffer.allocUnsafeSlow (can return a buffer backed by uninitialized memory). const bufferModule = require('node:buffer'); return { ...bufferModule, Buffer: blockMethods(bufferModule.Buffer, ['allocUnsafe', 'allocUnsafeSlow'], 'Buffer'), }; } else if (moduleName === 'util') { - // Block escape utils like util.inherits and util.debuglog - // util.inherits(ctor, superCtor) — directly manipulates the prototype chain ( - // util.debuglog(section) — conditionally writes to stderr based on the NODE_DEBUG environment variable + // Block util.inherits (can directly manipulate the prototype chain). + // Block util.debuglog (can write to stderr based on the NODE_DEBUG environment variable). return blockMethods(require('node:util'), ['inherits', 'debuglog'], 'util'); - + } else if (moduleName === 'lodash') { + const mod = externalModules.get('lodash')!; + // Block lodash.template (can compile strings in global scope). + // Block lodash.runInContext (runs code in a new global context). + return blockMethods(mod, ['template', 'runInContext'], 'lodash'); } else if ( [ // node.js modules @@ -74,13 +76,12 @@ export const requireInterceptor = (moduleName: string): any => { return moduleName === 'atob' ? atob : btoa; } else if ( [ - // external modules + // external modules (without lodash — handled above) 'ajv', 'chai', 'cheerio', 'crypto-js', 'csv-parse/lib/sync', - 'lodash', 'moment', 'tv4', 'uuid', diff --git a/packages/insomnia/src/scripting/sandbox.ts b/packages/insomnia/src/scripting/sandbox.ts index b9883cf62e10..11b9c540099a 100644 --- a/packages/insomnia/src/scripting/sandbox.ts +++ b/packages/insomnia/src/scripting/sandbox.ts @@ -25,14 +25,15 @@ export interface SandboxContext { bridgeOps: BridgeOps; } -// Derive the default blocked sets from the canonical rule lists in script-security-policy. const SANDBOX_BLOCKED_PROPERTIES = new Set(blockedPropertyRules.map(r => r.name)); const SANDBOX_BLOCKED_ROOTS = new Set(blockedRootRules.map(r => r.name)); -// These interceptor rules always apply — they cannot be disabled via settings and run even when -// the sandbox is turned off, because they gate access to critical host APIs (require, window, eval). +// The original (v12.5.0) interceptor rules always apply. const ALWAYS_ON_INTERCEPTORS = new Set(['require', 'window', 'eval']); +// AST check operates based on getMemberPropertyName, some keys may not be statically resolved. We can drop all unresolvable properties to resolve this. +const UNRESOLVABLE = Symbol('unresolvable'); + // Walks a MemberExpression down to its root Identifier. function getMemberRoot(node: any): string | null { if (node.type === 'Identifier') return node.name; @@ -40,8 +41,8 @@ function getMemberRoot(node: any): string | null { return null; } - // Returns MemberExpression property name. -function getMemberPropertyName(node: acorn.MemberExpression): string | null { +// Returns MemberExpression property name, or UNRESOLVABLE when the computed key cannot statically determined. +function getMemberPropertyName(node: acorn.MemberExpression): string | typeof UNRESOLVABLE | null { if (!node.computed && node.property.type === 'Identifier') { return (node.property as acorn.Identifier).name; } @@ -49,6 +50,49 @@ function getMemberPropertyName(node: acorn.MemberExpression): string | null { const val = (node.property as acorn.Literal).value; return typeof val === 'string' ? val : null; } + // No-expression template literal: `constructor` has a statically known value. + if (node.computed && node.property.type === 'TemplateLiteral') { + const tl = node.property as any; + if (tl.expressions.length === 0) { + const cooked: string | null = tl.quasis[0].value.cooked; + return cooked !== null ? cooked : UNRESOLVABLE; + } + return UNRESOLVABLE; + } + // Any other computed expression (BinaryExpression, CallExpression, etc.) cannot be verified. + if (node.computed) { + return UNRESOLVABLE; + } + return null; +} + +// Recursively walk AST expression for blocked identifier. +function extractBlockedIdentifier( + expr: any, + blocked: Map, + blockedRoots: Set, +): string | null { + if (!expr) return null; + if (expr.type === 'Identifier') { + return blocked.has(expr.name) ? expr.name : null; + } + if (expr.type === 'ThisExpression') { + return blockedRoots.has('this') ? 'this' : null; + } + if (expr.type === 'LogicalExpression') { + return extractBlockedIdentifier(expr.left, blocked, blockedRoots) + ?? extractBlockedIdentifier(expr.right, blocked, blockedRoots); + } + if (expr.type === 'ConditionalExpression') { + return extractBlockedIdentifier(expr.consequent, blocked, blockedRoots) + ?? extractBlockedIdentifier(expr.alternate, blocked, blockedRoots); + } + if (expr.type === 'SequenceExpression') { + for (const e of expr.expressions) { + const hit = extractBlockedIdentifier(e, blocked, blockedRoots); + if (hit !== null) return hit; + } + } return null; } @@ -69,6 +113,7 @@ export function checkSandboxViolations( script: string, blockedProperties: Set = SANDBOX_BLOCKED_PROPERTIES, blockedRoots: Set = SANDBOX_BLOCKED_ROOTS, + blockDynamic = true, ): void { let tree: any; for (const sourceType of ['module', 'script'] as const) { @@ -106,36 +151,22 @@ export function checkSandboxViolations( }; walk.simple(tree, { - // const/let/var g = globalThis OR const s = this + // const/let/var g = globalThis OR const s = this OR const g = null || globalThis VariableDeclarator(node: acorn.VariableDeclarator) { if (node.id.type !== 'Identifier') return; const id = node.id as acorn.Identifier; - if (node.init?.type === 'Identifier') { - const initName = (node.init as acorn.Identifier).name; - const origin = blocked.get(initName); - if (origin !== undefined) { - blocked.set(id.name, origin); - } - } - // `this` is a ThisExpression, not an Identifier — handle separately. - // Only track aliases if the 'this' rule is active in the current policy. - if (node.init?.type === 'ThisExpression' && blockedRoots.has('this')) { - blocked.set(id.name, 'this'); + const foundName = extractBlockedIdentifier(node.init, blocked, blockedRoots); + if (foundName !== null) { + blocked.set(id.name, blocked.get(foundName) ?? foundName); } }, - // g = globalThis (bare assignment) OR s = this + // g = globalThis OR g = (0, globalThis) OR g = false ? null : globalThis AssignmentExpression(node: acorn.AssignmentExpression) { if (node.left.type !== 'Identifier') return; const id = node.left as acorn.Identifier; - if (node.right.type === 'Identifier') { - const rightName = (node.right as acorn.Identifier).name; - const origin = blocked.get(rightName); - if (origin !== undefined) { - blocked.set(id.name, origin); - } - } - if (node.right.type === 'ThisExpression' && blockedRoots.has('this')) { - blocked.set(id.name, 'this'); + const foundName = extractBlockedIdentifier(node.right, blocked, blockedRoots); + if (foundName !== null) { + blocked.set(id.name, blocked.get(foundName) ?? foundName); } }, }); @@ -161,6 +192,15 @@ export function checkSandboxViolations( // obj.constructor, obj['__proto__'], obj.getPrototypeOf, etc. const prop = getMemberPropertyName(node); + if (prop === UNRESOLVABLE) { + if (blockDynamic) { + throw new Error( + `The script was blocked because it used a dynamic computed property access that cannot be statically verified.\n` + + `Use dot notation or a string literal key instead, or disable 'block unresolvable properties' via Settings → Scripting → Script Sandbox.`, + ); + } + return; + } if (prop && blockedProperties.has(prop)) { throw new Error( `The script was blocked because it used the property '${prop}'.\n` + @@ -306,15 +346,19 @@ export async function prepareSandbox( let sandboxContext = context; let maskNames: string[] = []; let maskValues: unknown[] = []; + // Captured below so the scoped-eval patch (applied after both branches) uses the right checker. + let evalViolationCheck: (s: string) => void = checkSandboxViolations; if (context.settings.scriptSandboxEnabled !== false) { const disabledProps = new Set(context.settings.disabledBlockedProperties); const disabledRoots = new Set(context.settings.disabledBlockedRoots); const activeProperties = new Set([...SANDBOX_BLOCKED_PROPERTIES].filter(p => !disabledProps.has(p))); const activeRoots = new Set([...SANDBOX_BLOCKED_ROOTS].filter(r => !disabledRoots.has(r))); + const blockDynamic = context.settings.scriptBlockUnresolvableProperties !== false; // Bind the filtered checker so eval-intercept uses the same active policy. - const activeSandboxCheck = (s: string) => checkSandboxViolations(s, activeProperties, activeRoots); + const activeSandboxCheck = (s: string) => checkSandboxViolations(s, activeProperties, activeRoots, blockDynamic); + evalViolationCheck = activeSandboxCheck; try { activeSandboxCheck(script); @@ -344,6 +388,25 @@ export async function prepareSandbox( ({ names: maskNames, values: maskValues } = alwaysOnPolicy.buildMaskScope(checkSandboxViolations)); } + // Wrap eval so user-supplied source is checked and then evaluated in a scope that inherits the same masked bindings as the outer sandbox. + const evalIdx = maskNames.indexOf('eval'); + if (evalIdx !== -1) { + const useStrict = context.settings.scriptStrictModeEnabled !== false; + const body = `${useStrict ? '"use strict"; ' : ''}return eval(__eval_script__);`; + // 'eval' is excluded from params: it's illegal as a strict-mode param name, + // and the wrapper must call the real eval for direct-eval scoping to apply. + const paramNames = maskNames.filter(n => n !== 'eval'); + const paramValues = maskValues.filter((_, i) => maskNames[i] !== 'eval'); + const scopedEvalFn = new Function(...paramNames, '__eval_script__', body); + maskValues[evalIdx] = (script: string) => { + if (!script || typeof script !== 'string') { + throw new Error('eval is called with invalid or empty value'); + } + evalViolationCheck(script); + return scopedEvalFn(...paramValues, script); + }; + } + const executionContext = await initInsomniaObject(sandboxContext, scriptConsole.log); const bridgeOps: BridgeOps = { diff --git a/packages/insomnia/src/scripting/script-security-policy.ts b/packages/insomnia/src/scripting/script-security-policy.ts index 5e566891709d..ab5ba5b713a1 100644 --- a/packages/insomnia/src/scripting/script-security-policy.ts +++ b/packages/insomnia/src/scripting/script-security-policy.ts @@ -96,8 +96,6 @@ export const interceptorRules: ThreatRule[] = [ buildMaskValue: violationCheck => (script: string) => { invariant(script && typeof script === 'string', 'eval is called with invalid or empty value'); violationCheck(script); - - return (0, eval)(script); }, }, @@ -159,4 +157,39 @@ export const maskRules: ThreatRule[] = [ maskName: 'WebAssembly', maskValue: undefined, }, + // The five entries below mirror identifiers that are also in blockedRootRules (AST-level). + // They are duplicated here as runtime masks because the AST alias tracker only follows + // VariableDeclarator / AssignmentExpression — a function that returns a blocked root (e.g. + // `function() { return module; }`) passes the static check. Runtime masking to undefined + // is the only complete defence against indirect access patterns. + { + name: 'module', + description: 'Prevents access to the Node.js module object to block module.require() and module.children, which expose the full unfiltered module graph and bypass the require interceptor.', + maskName: 'module', + maskValue: undefined, + }, + { + name: 'self', + description: 'Prevents access to the self global (Web Worker / browser alias for window) to block self.require() and other host APIs reachable via the real global object, bypassing the window mask.', + maskName: 'self', + maskValue: undefined, + }, + { + name: 'exports', + description: 'Prevents access to the Node.js exports object to block indirect traversal of the live module cache.', + maskName: 'exports', + maskValue: undefined, + }, + { + name: 'Buffer', + description: 'Prevents access to the Node.js Buffer global to block allocUnsafe(), which reads uninitialised heap memory, and indirect require() via the Buffer module object.', + maskName: 'Buffer', + maskValue: undefined, + }, + { + name: 'frames', + description: 'Prevents access to the window.frames collection to block navigation to an unsandboxed global scope via frame traversal.', + maskName: 'frames', + maskValue: undefined, + }, ]; diff --git a/packages/insomnia/src/ui/components/settings/scripting-settings.tsx b/packages/insomnia/src/ui/components/settings/scripting-settings.tsx index 96a044ff4aac..c4dc9e88d304 100644 --- a/packages/insomnia/src/ui/components/settings/scripting-settings.tsx +++ b/packages/insomnia/src/ui/components/settings/scripting-settings.tsx @@ -132,12 +132,13 @@ export const ScriptingSettings = () => { const sandboxEnabled = settings.scriptSandboxEnabled !== false; const strictModeEnabled = settings.scriptStrictModeEnabled !== false; + const blockUnresolvableEnabled = settings.scriptBlockUnresolvableProperties !== false; const disabledRules = settings.disabledSecurityRules ?? []; const disabledProperties = settings.disabledBlockedProperties ?? []; const disabledRoots = settings.disabledBlockedRoots ?? []; const GROUPED_MASK_NAMES = new Set([ - 'globalThis', 'global', 'process', + 'globalThis', 'global', 'process', 'module', 'exports', 'Buffer', 'self', 'frames', 'setImmediate', 'queueMicrotask', 'Proxy', 'Reflect', 'Function', 'WebAssembly', @@ -147,7 +148,7 @@ export const ScriptingSettings = () => { { title: 'Global & Node.js Internals', description: 'References to the global scope and Node.js process information such as environment variables and runtime state.', - rules: maskRules.filter(r => ['globalThis', 'global', 'process'].includes(r.name)), + rules: maskRules.filter(r => ['globalThis', 'global', 'process', 'module', 'exports', 'Buffer', 'self', 'frames'].includes(r.name)), }, { title: 'Async Scheduling', @@ -248,7 +249,7 @@ export const ScriptingSettings = () => { -
+
{ isDisabled={!sandboxEnabled} onChange={enabled => patchSettings({ scriptStrictModeEnabled: enabled })} /> + patchSettings({ scriptBlockUnresolvableProperties: enabled })} + />