chore(sandbox): Added more smoke tests

This commit is contained in:
Kyle
2026-05-14 13:48:37 -04:00
parent 27e3a91519
commit a78fbd2ce0
2 changed files with 263 additions and 45 deletions

View File

@@ -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');
});
});

View File

@@ -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);
};
}