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 4881f96758..2f019807a3 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/scripting/sandbox.ts b/packages/insomnia/src/scripting/sandbox.ts index 6ab1ba9bce..11b9c54009 100644 --- a/packages/insomnia/src/scripting/sandbox.ts +++ b/packages/insomnia/src/scripting/sandbox.ts @@ -25,16 +25,13 @@ 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']); -// Sentinel returned by getMemberPropertyName when the computed key cannot be statically resolved. -// Callers must treat this as a hard block — unknown dynamic keys are rejected by policy. +// 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. @@ -44,8 +41,7 @@ function getMemberRoot(node: any): string | null { return null; } -// Returns MemberExpression property name, or UNRESOLVABLE when the computed key cannot be -// statically determined (e.g. BinaryExpression, dynamic TemplateLiteral). +// 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; @@ -392,33 +388,22 @@ export async function prepareSandbox( ({ names: maskNames, values: maskValues } = alwaysOnPolicy.buildMaskScope(checkSandboxViolations)); } - // Replace the placeholder eval interceptor with one that carries the full parameter mask. - // The rule-level interceptor uses (0,eval) (indirect eval in global scope), which bypasses - // parameter masking and lets scripts access real globals like require, Function, process. - // Here we patch maskValues to use a new AsyncFunction with the same params and a direct eval, - // so eval'd code inherits the masked parameter bindings. + // 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 strictModeEnabled = context.settings.scriptStrictModeEnabled !== false; - const evalBody = strictModeEnabled - ? '"use strict"; return eval(__eval_script__);' - : 'return eval(__eval_script__);'; - // Exclude 'eval' from the parameter list: naming a parameter 'eval' is illegal in strict mode, - // and the function needs to call the real eval (not the interceptor) for direct-eval scoping. - // Use a synchronous Function (not AsyncFunction) to preserve the synchronous return contract — - // AsyncFunction always returns a Promise, which breaks postMessage structured-clone for sync scripts. - const nonEvalMaskNames = maskNames.filter(n => n !== 'eval'); - const nonEvalMaskValues = maskValues.filter((_, i) => maskNames[i] !== 'eval'); - const scopedEvalFn = new Function( - ...nonEvalMaskNames, '__eval_script__', - evalBody, - ); + 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(...nonEvalMaskValues, script); + return scopedEvalFn(...paramValues, script); }; }