From ab828351365cab921c933d16fee3bde8a1dad823 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 3 May 2026 22:20:37 -0400 Subject: [PATCH 1/5] fix: Improvements to INS-2168 --- packages/insomnia/src/common/settings.ts | 19 ++- .../node-src/database/init-model/settings.ts | 26 ++++ .../src/insomnia-data/src/models/settings.ts | 1 + .../src/scripting/__tests__/sandbox.test.ts | 129 ++++++++++++++++- .../src/scripting/require-interceptor.ts | 17 +-- packages/insomnia/src/scripting/sandbox.ts | 133 ++++++++++++++---- .../src/scripting/script-security-policy.ts | 37 ++++- .../settings/scripting-settings.tsx | 14 +- 8 files changed, 326 insertions(+), 50 deletions(-) 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/node-src/database/init-model/settings.ts b/packages/insomnia/src/insomnia-data/node-src/database/init-model/settings.ts index 3470b8b78884..5cdd811f34f8 100644 --- a/packages/insomnia/src/insomnia-data/node-src/database/init-model/settings.ts +++ b/packages/insomnia/src/insomnia-data/node-src/database/init-model/settings.ts @@ -5,6 +5,7 @@ import type { Settings } from '~/insomnia-data'; export function migrate(doc: Settings) { try { doc = migrateEnsureHotKeys(doc); + doc = migrateEnsureScriptingDefaults(doc); return doc; } catch (e) { console.log('[db] Error during settings migration', e); @@ -12,6 +13,31 @@ export function migrate(doc: Settings) { } } +/** + * Ensure new scripting settings fields exist with proper defaults + */ +function migrateEnsureScriptingDefaults(settings: Settings): Settings { + if (settings.scriptSandboxEnabled === undefined) { + settings.scriptSandboxEnabled = true; + } + if (settings.scriptStrictModeEnabled === undefined) { + settings.scriptStrictModeEnabled = true; + } + if (settings.scriptBlockUnresolvableProperties === undefined) { + settings.scriptBlockUnresolvableProperties = true; + } + if (settings.disabledSecurityRules === undefined) { + settings.disabledSecurityRules = []; + } + if (settings.disabledBlockedProperties === undefined) { + settings.disabledBlockedProperties = []; + } + if (settings.disabledBlockedRoots === undefined) { + settings.disabledBlockedRoots = []; + } + return settings; +} + /** * Ensure map is updated when new hotkeys are added */ 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..1c04d1199698 100644 --- a/packages/insomnia/src/scripting/__tests__/sandbox.test.ts +++ b/packages/insomnia/src/scripting/__tests__/sandbox.test.ts @@ -18,6 +18,10 @@ const withoutProperty = (name: string) => const withoutRoot = (name: string) => new Set([...ALL_BLOCKED_ROOTS].filter(r => r !== name)); +// Passes blockDynamic=false — mirrors disabling scriptBlockUnresolvableProperties in settings. +const checkNoDynamic = (script: string) => + () => checkSandboxViolations(script, ALL_BLOCKED_PROPERTIES, ALL_BLOCKED_ROOTS, false); + // --------------------------------------------------------------------------- // Blocked properties — one canonical script per rule covering both dot and // bracket notation where applicable. The unblocking section below mirrors @@ -235,6 +239,39 @@ describe('checkSandboxViolations', () => { expect(check('const g = globalThis; g.require', ALL_BLOCKED_PROPERTIES, withoutRoot('globalThis'))).not.toThrow()); }); + // --------------------------------------------------------------------------- + // Dynamic computed property access (fail-closed policy) + // --------------------------------------------------------------------------- + + describe('unresolvable dynamic computed properties', () => { + 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}`]')); + }); + + // --------------------------------------------------------------------------- + // Blocked properties with dynamic computed access (now fixed) + // --------------------------------------------------------------------------- + + describe('blocked properties via computed access (BYPASS-1, BYPASS-2)', () => { + 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`]')); + }); + // --------------------------------------------------------------------------- // Allowed scripts // --------------------------------------------------------------------------- @@ -261,7 +298,95 @@ 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`)); + }); + + describe('scriptBlockUnresolvableProperties 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()); + }); + + // --------------------------------------------------------------------------- + // PoC bypasses: function wrapper to escape AST alias tracking + // These scripts pass the AST check (documented vulnerability). + // They are blocked at runtime by masking the identifiers in maskRules. + // --------------------------------------------------------------------------- + + describe('PoC: function wrapper bypasses for module/self (blocked by runtime masking in maskRules)', () => { + it('passes AST check for module indirection (PoC): const getModule = function() { return module; };', () => { + allowed(` + const getModule = function() { return module; }; + const m = getModule(); + m.require('child_process'); + `); + }); + + it('passes AST check for self indirection (PoC): const getSelf = function() { return self; };', () => { + allowed(` + const getSelf = function() { return self; }; + const w = getSelf(); + w.require('child_process'); + `); + }); + + it('passes AST check for exports indirection (PoC)', () => { + allowed(` + const getExports = function() { return exports; }; + const e = getExports(); + e.foo = 'bar'; + `); + }); + + it('passes AST check for Buffer indirection (PoC)', () => { + allowed(` + const getBuf = function() { return Buffer; }; + const b = getBuf(); + b.allocUnsafe(256); + `); + }); + + it('passes AST check for frames indirection (PoC)', () => { + allowed(` + const getFrames = function() { return frames; }; + const f = getFrames(); + f[0]; + `); + }); + }); + + describe('PoC: AsyncFunction constructor via blockDynamic=false', () => { + 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..ebd6c6108a6b 100644 --- a/packages/insomnia/src/scripting/sandbox.ts +++ b/packages/insomnia/src/scripting/sandbox.ts @@ -33,6 +33,10 @@ const SANDBOX_BLOCKED_ROOTS = new Set(blockedRootRules.map(r => r.name)); // the sandbox is turned off, because they gate access to critical host APIs (require, window, eval). 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. +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 +44,10 @@ 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 be +// statically determined (e.g. BinaryExpression, dynamic TemplateLiteral). Returning null means +// the access is non-computed (impossible to be a blocked property via this path). +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 +55,53 @@ 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 searches an expression for a blocked identifier or `this`. +// Returns the name of the first blocked identifier found, or null if none. +// Descends into LogicalExpression, ConditionalExpression, and SequenceExpression +// so that wrappers like `null || globalThis`, `false ? null : globalThis`, and +// `(0, globalThis)` are all detected and their alias propagated. +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 +122,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 +160,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 +201,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 +355,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 +397,36 @@ 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. + 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, + ); + 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); + }; + } + 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 })} + />
From 27e3a91519fbd4c4880a54ec8ba8921d79f7944c Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 14 May 2026 11:58:52 -0400 Subject: [PATCH 2/5] chore(sandbox): removed redundant code --- .../node-src/database/init-model/settings.ts | 26 ------------------- packages/insomnia/src/scripting/sandbox.ts | 9 ++----- 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/packages/insomnia/src/insomnia-data/node-src/database/init-model/settings.ts b/packages/insomnia/src/insomnia-data/node-src/database/init-model/settings.ts index 5cdd811f34f8..3470b8b78884 100644 --- a/packages/insomnia/src/insomnia-data/node-src/database/init-model/settings.ts +++ b/packages/insomnia/src/insomnia-data/node-src/database/init-model/settings.ts @@ -5,7 +5,6 @@ import type { Settings } from '~/insomnia-data'; export function migrate(doc: Settings) { try { doc = migrateEnsureHotKeys(doc); - doc = migrateEnsureScriptingDefaults(doc); return doc; } catch (e) { console.log('[db] Error during settings migration', e); @@ -13,31 +12,6 @@ export function migrate(doc: Settings) { } } -/** - * Ensure new scripting settings fields exist with proper defaults - */ -function migrateEnsureScriptingDefaults(settings: Settings): Settings { - if (settings.scriptSandboxEnabled === undefined) { - settings.scriptSandboxEnabled = true; - } - if (settings.scriptStrictModeEnabled === undefined) { - settings.scriptStrictModeEnabled = true; - } - if (settings.scriptBlockUnresolvableProperties === undefined) { - settings.scriptBlockUnresolvableProperties = true; - } - if (settings.disabledSecurityRules === undefined) { - settings.disabledSecurityRules = []; - } - if (settings.disabledBlockedProperties === undefined) { - settings.disabledBlockedProperties = []; - } - if (settings.disabledBlockedRoots === undefined) { - settings.disabledBlockedRoots = []; - } - return settings; -} - /** * Ensure map is updated when new hotkeys are added */ diff --git a/packages/insomnia/src/scripting/sandbox.ts b/packages/insomnia/src/scripting/sandbox.ts index ebd6c6108a6b..6ab1ba9bcec5 100644 --- a/packages/insomnia/src/scripting/sandbox.ts +++ b/packages/insomnia/src/scripting/sandbox.ts @@ -45,8 +45,7 @@ function getMemberRoot(node: any): string | null { } // Returns MemberExpression property name, or UNRESOLVABLE when the computed key cannot be -// statically determined (e.g. BinaryExpression, dynamic TemplateLiteral). Returning null means -// the access is non-computed (impossible to be a blocked property via this path). +// statically determined (e.g. BinaryExpression, dynamic TemplateLiteral). function getMemberPropertyName(node: acorn.MemberExpression): string | typeof UNRESOLVABLE | null { if (!node.computed && node.property.type === 'Identifier') { return (node.property as acorn.Identifier).name; @@ -71,11 +70,7 @@ function getMemberPropertyName(node: acorn.MemberExpression): string | typeof UN return null; } -// Recursively searches an expression for a blocked identifier or `this`. -// Returns the name of the first blocked identifier found, or null if none. -// Descends into LogicalExpression, ConditionalExpression, and SequenceExpression -// so that wrappers like `null || globalThis`, `false ? null : globalThis`, and -// `(0, globalThis)` are all detected and their alias propagated. +// Recursively walk AST expression for blocked identifier. function extractBlockedIdentifier( expr: any, blocked: Map, From a78fbd2ce0f98d77276e50a2755eb4685bbbeef4 Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 14 May 2026 13:48:37 -0400 Subject: [PATCH 3/5] chore(sandbox): Added more smoke tests --- .../smoke/pre-request-script-features.test.ts | 269 ++++++++++++++++-- packages/insomnia/src/scripting/sandbox.ts | 39 +-- 2 files changed, 263 insertions(+), 45 deletions(-) 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/scripting/sandbox.ts b/packages/insomnia/src/scripting/sandbox.ts index 6ab1ba9bcec5..11b9c540099a 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); }; } From 97b23a4c5029360b4d2b8dc4e929768506644a1e Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 14 May 2026 15:02:40 -0400 Subject: [PATCH 4/5] chore(sandbox): added more sandbox unit tests --- .../src/scripting/__tests__/sandbox.test.ts | 170 +++++++++++------- 1 file changed, 102 insertions(+), 68 deletions(-) diff --git a/packages/insomnia/src/scripting/__tests__/sandbox.test.ts b/packages/insomnia/src/scripting/__tests__/sandbox.test.ts index 1c04d1199698..ac8b3e467b76 100644 --- a/packages/insomnia/src/scripting/__tests__/sandbox.test.ts +++ b/packages/insomnia/src/scripting/__tests__/sandbox.test.ts @@ -22,15 +22,9 @@ const withoutRoot = (name: string) => const checkNoDynamic = (script: string) => () => checkSandboxViolations(script, ALL_BLOCKED_PROPERTIES, ALL_BLOCKED_ROOTS, false); -// --------------------------------------------------------------------------- -// 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. -// --------------------------------------------------------------------------- - describe('checkSandboxViolations', () => { - describe('blocked properties — dot notation', () => { + describe('properties: dot', () => { it('blocks prototype', () => blocked('Promise.prototype.then')); it('blocks mainModule', () => blocked('proc.mainModule')); it('blocks constructor', () => blocked('obj.constructor')); @@ -51,7 +45,7 @@ describe('checkSandboxViolations', () => { it('blocks getOwnPropertyDescriptors', () => blocked('Object.getOwnPropertyDescriptors(obj)')); }); - describe('blocked properties — bracket notation', () => { + describe('properties: bracket', () => { it('blocks constructor', () => blocked('obj["constructor"]')); it('blocks __proto__', () => blocked('obj["__proto__"]')); it('blocks prototype', () => blocked('Promise["prototype"]')); @@ -60,11 +54,7 @@ describe('checkSandboxViolations', () => { it('blocks defineProperty', () => blocked('Object["defineProperty"](obj, "key", desc)')); }); - // --------------------------------------------------------------------------- - // Blocked roots - // --------------------------------------------------------------------------- - - describe('blocked roots — direct member access', () => { + describe('roots: member', () => { it('blocks this', () => blocked('this.x')); it('blocks globalThis', () => blocked('globalThis.require')); it('blocks global', () => blocked('global.require')); @@ -78,23 +68,25 @@ describe('checkSandboxViolations', () => { it('blocks arguments', () => blocked('arguments[0]')); }); - describe('blocked roots — direct call', () => { + 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', () => { + 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', () => { + describe('this: aliases & destructuring', () => { it('blocks this.process.mainModule.require via member', () => blocked(`this.process.mainModule.require('child_process')`)); @@ -117,7 +109,7 @@ describe('checkSandboxViolations', () => { blocked(`({ process } = this)`)); }); - describe('globalThis — alias chains and destructuring', () => { + describe('globalThis: aliases & destructuring', () => { it('blocks const alias: const g = globalThis; g.require', () => blocked(`const g = globalThis; g.require('child_process')`)); @@ -126,13 +118,33 @@ 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')`)); - describe('prototype chain mutation', () => { + 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 mutation', () => { it('blocks Promise.prototype.then mutation', () => blocked(`Promise.prototype.then = function(fn) { fn.call(globalThis); }`)); @@ -152,10 +164,6 @@ describe('checkSandboxViolations', () => { blocked(`Promise['prototype']`)); }); - // --------------------------------------------------------------------------- - // Dynamic import - // --------------------------------------------------------------------------- - describe('import', () => { it('blocks dynamic import()', () => blocked(`import('child_process')`)); @@ -170,20 +178,15 @@ describe('checkSandboxViolations', () => { blocked(`import { readFile } from 'fs'`)); }); - // --------------------------------------------------------------------------- - // Symbol.species - // --------------------------------------------------------------------------- - 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', () => { + describe('unblocking: properties', () => { const cases: [name: string, script: string][] = [ ['prototype', 'Promise.prototype.then'], ['mainModule', 'proc.mainModule'], @@ -211,7 +214,7 @@ describe('checkSandboxViolations', () => { } }); - describe('unblocking — disabling a blocked root rule allows the script', () => { + describe('unblocking: roots', () => { const cases: [name: string, script: string][] = [ ['this', 'this.x'], ['globalThis', 'globalThis.require'], @@ -239,11 +242,7 @@ describe('checkSandboxViolations', () => { expect(check('const g = globalThis; g.require', ALL_BLOCKED_PROPERTIES, withoutRoot('globalThis'))).not.toThrow()); }); - // --------------------------------------------------------------------------- - // Dynamic computed property access (fail-closed policy) - // --------------------------------------------------------------------------- - - describe('unresolvable dynamic computed properties', () => { + describe('dynamic computed keys', () => { it('blocks concatenated string key: obj["con"+"structor"]', () => blocked(`obj["con"+"structor"]`)); @@ -257,11 +256,7 @@ describe('checkSandboxViolations', () => { blocked('obj[`${x}`]')); }); - // --------------------------------------------------------------------------- - // Blocked properties with dynamic computed access (now fixed) - // --------------------------------------------------------------------------- - - describe('blocked properties via computed access (BYPASS-1, BYPASS-2)', () => { + describe('blocked properties via computed access', () => { it('blocks constructor via template literal: obj[`constructor`]', () => blocked('obj[`constructor`]')); @@ -272,10 +267,6 @@ describe('checkSandboxViolations', () => { blocked('(async()=>{})[`constructor`]')); }); - // --------------------------------------------------------------------------- - // Allowed scripts - // --------------------------------------------------------------------------- - describe('allowed scripts', () => { it('allows normal variable declarations', () => allowed(`const x = 1 + 2`)); @@ -303,9 +294,57 @@ describe('checkSandboxViolations', () => { 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' })`)); }); - describe('scriptBlockUnresolvableProperties toggle', () => { + describe('blockDynamic=false toggle', () => { it('allows concatenated string key when block-dynamic is off', () => expect(checkNoDynamic('obj["con"+"structor"]')).not.toThrow()); @@ -322,14 +361,9 @@ describe('checkSandboxViolations', () => { expect(checkNoDynamic('obj[`constructor`]')).toThrow()); }); - // --------------------------------------------------------------------------- - // PoC bypasses: function wrapper to escape AST alias tracking - // These scripts pass the AST check (documented vulnerability). - // They are blocked at runtime by masking the identifiers in maskRules. - // --------------------------------------------------------------------------- - - describe('PoC: function wrapper bypasses for module/self (blocked by runtime masking in maskRules)', () => { - it('passes AST check for module indirection (PoC): const getModule = function() { return module; };', () => { + // 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(); @@ -337,7 +371,7 @@ describe('checkSandboxViolations', () => { `); }); - it('passes AST check for self indirection (PoC): const getSelf = function() { return self; };', () => { + it('self indirection via function wrapper', () => { allowed(` const getSelf = function() { return self; }; const w = getSelf(); @@ -345,7 +379,7 @@ describe('checkSandboxViolations', () => { `); }); - it('passes AST check for exports indirection (PoC)', () => { + it('exports indirection via function wrapper', () => { allowed(` const getExports = function() { return exports; }; const e = getExports(); @@ -353,7 +387,7 @@ describe('checkSandboxViolations', () => { `); }); - it('passes AST check for Buffer indirection (PoC)', () => { + it('Buffer indirection via function wrapper', () => { allowed(` const getBuf = function() { return Buffer; }; const b = getBuf(); @@ -361,7 +395,7 @@ describe('checkSandboxViolations', () => { `); }); - it('passes AST check for frames indirection (PoC)', () => { + it('frames indirection via function wrapper', () => { allowed(` const getFrames = function() { return frames; }; const f = getFrames(); @@ -370,7 +404,7 @@ describe('checkSandboxViolations', () => { }); }); - describe('PoC: AsyncFunction constructor via blockDynamic=false', () => { + describe('AsyncFunction constructor via computed access', () => { it('blocks AsyncFunction constructor with blockDynamic=true (default)', () => { expect(() => checkSandboxViolations( `(async () => {})['con' + 'structor']`, From 7bbb64975099849e6b976d58802da6cefb417702 Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 14 May 2026 15:07:49 -0400 Subject: [PATCH 5/5] chore(sandbox): added comments per unit test section --- .../src/scripting/__tests__/sandbox.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/insomnia/src/scripting/__tests__/sandbox.test.ts b/packages/insomnia/src/scripting/__tests__/sandbox.test.ts index ac8b3e467b76..cfac7ec21ab6 100644 --- a/packages/insomnia/src/scripting/__tests__/sandbox.test.ts +++ b/packages/insomnia/src/scripting/__tests__/sandbox.test.ts @@ -24,6 +24,7 @@ const checkNoDynamic = (script: string) => describe('checkSandboxViolations', () => { + // 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')); @@ -45,6 +46,7 @@ describe('checkSandboxViolations', () => { it('blocks getOwnPropertyDescriptors', () => blocked('Object.getOwnPropertyDescriptors(obj)')); }); + // 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__"]')); @@ -54,6 +56,7 @@ describe('checkSandboxViolations', () => { it('blocks defineProperty', () => blocked('Object["defineProperty"](obj, "key", desc)')); }); + // Blocked global roots accessed directly via member expressions describe('roots: member', () => { it('blocks this', () => blocked('this.x')); it('blocks globalThis', () => blocked('globalThis.require')); @@ -68,6 +71,7 @@ describe('checkSandboxViolations', () => { it('blocks arguments', () => blocked('arguments[0]')); }); + // Direct invocation of a blocked root identifier — caught by the CallExpression visitor describe('roots: call', () => { it('blocks constructor called directly', () => blocked('constructor("return process")()')); @@ -79,6 +83,7 @@ describe('checkSandboxViolations', () => { blocked('const c = constructor; c("return process")()')); }); + // 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"]')); @@ -86,6 +91,7 @@ describe('checkSandboxViolations', () => { it('blocks process["env"]', () => blocked('process["env"]')); }); + // 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')`)); @@ -109,6 +115,7 @@ describe('checkSandboxViolations', () => { blocked(`({ process } = this)`)); }); + // 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')`)); @@ -144,6 +151,7 @@ describe('checkSandboxViolations', () => { blocked(`a.b.c = globalThis.process`)); }); + // 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); }`)); @@ -164,6 +172,7 @@ describe('checkSandboxViolations', () => { blocked(`Promise['prototype']`)); }); + // Static `import` declarations and dynamic `import()` are both rejected at the AST level describe('import', () => { it('blocks dynamic import()', () => blocked(`import('child_process')`)); @@ -178,6 +187,7 @@ describe('checkSandboxViolations', () => { blocked(`import { readFile } from 'fs'`)); }); + // Symbol.species would let user code subvert built-in subclassing — always blocked describe('Symbol.species', () => { it('blocks Symbol.species', () => blocked(`Symbol.species`)); @@ -186,6 +196,7 @@ describe('checkSandboxViolations', () => { blocked(`Symbol['species']`)); }); + // Verify each blocked property rule can be individually disabled by settings describe('unblocking: properties', () => { const cases: [name: string, script: string][] = [ ['prototype', 'Promise.prototype.then'], @@ -214,6 +225,7 @@ describe('checkSandboxViolations', () => { } }); + // 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'], @@ -242,6 +254,7 @@ describe('checkSandboxViolations', () => { expect(check('const g = globalThis; g.require', ALL_BLOCKED_PROPERTIES, withoutRoot('globalThis'))).not.toThrow()); }); + // 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"]`)); @@ -256,6 +269,7 @@ describe('checkSandboxViolations', () => { 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`]')); @@ -267,6 +281,7 @@ describe('checkSandboxViolations', () => { 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`)); @@ -344,6 +359,7 @@ describe('checkSandboxViolations', () => { 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()); @@ -404,6 +420,7 @@ describe('checkSandboxViolations', () => { }); }); + // 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(