diff --git a/packages/insomnia/src/templating/sandbox/sandbox-export-discovery.test.ts b/packages/insomnia/src/templating/sandbox/sandbox-export-discovery.test.ts index c3942c4aa6..0e851a1cb8 100644 --- a/packages/insomnia/src/templating/sandbox/sandbox-export-discovery.test.ts +++ b/packages/insomnia/src/templating/sandbox/sandbox-export-discovery.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { type HostBridge } from './host-bridge'; import { type ContextEnvelope, type PluginExportManifest } from './marshal'; import { runTagInSandbox } from './plugin-tag-sandbox'; +import { buildReachabilityProbeSource } from './sandbox-surface'; // L1 load-time discovery: evaluate a plugin's entry inside the sandbox and return a manifest of its // exports, instead of nodeRequire-ing it on the host. The plugin's top-level code runs — but in the @@ -15,6 +16,7 @@ const noBridge: HostBridge = async path => { const envelope = ( moduleFiles: Record, grantedModules: string[] = ['path', 'crypto'], + grantedCapabilities: string[] = [], ): ContextEnvelope => ({ args: [], context: {}, @@ -24,7 +26,7 @@ const envelope = ( pluginName: 'test-plugin', renderDepth: 0, grantedModules, - grantedCapabilities: [], + grantedCapabilities, moduleFiles, entryModuleKey: 'index.js', }); @@ -105,3 +107,59 @@ describe('sandbox export discovery (L1)', () => { ).rejects.toThrow(/not permitted by manifest/); }); }); + +describe('sandbox export discovery (L1) — bridge isolation', () => { + it('a discovery-time call to the internal context builder never reaches the host bridge', async () => { + const calls: { path: string; body: unknown }[] = []; + const recordingBridge: HostBridge = async (path, body) => { + calls.push({ path, body }); + return {}; + }; + + // Top-level code (runs during mere discovery, before any tag is ever invoked) reaches for the + // internal context builder directly and tries to use it — exactly what a plugin manifest never + // declaring anything would still be able to attempt. + await runTagInSandbox({ + tagName: '', + discover: true, + envelope: envelope( + { + 'index.js': ` + var ctx = globalThis.__buildContext({ pluginName: 'x', context: {}, meta: {}, renderPurpose: 'preview', appInfo: {} }); + ctx.app.alert('t', 'm'); + module.exports.templateTags = [];`, + }, + ['path', 'crypto'], + ['app'], + ), + bridge: recordingBridge, + }); + + expect(calls).toEqual([]); + }); + + // Stretch coverage: instead of one hand-picked exploit shape, recursively invoke every + // globalThis-reachable function (and whatever it returns) during discovery, watching for any + // bridge call anywhere in the tree. + it('no globalThis-reachable function reaches the host bridge during discovery, however invoked', async () => { + const calls: { path: string; body: unknown }[] = []; + const recordingBridge: HostBridge = async (path, body) => { + calls.push({ path, body }); + return {}; + }; + + await runTagInSandbox({ + tagName: '', + discover: true, + envelope: envelope( + { 'index.js': buildReachabilityProbeSource() }, + ['path', 'crypto'], + ['app', 'storage', 'network', 'fs-read', 'render', 'util', 'credentials', 'models.read'], + ), + bridge: recordingBridge, + timeoutMs: 30_000, + }); + + expect(calls).toEqual([]); + }, 40_000); +}); diff --git a/packages/insomnia/src/templating/sandbox/sandbox-surface.test.ts b/packages/insomnia/src/templating/sandbox/sandbox-surface.test.ts index e9da84db00..b8aa9a469a 100644 --- a/packages/insomnia/src/templating/sandbox/sandbox-surface.test.ts +++ b/packages/insomnia/src/templating/sandbox/sandbox-surface.test.ts @@ -3,10 +3,12 @@ import { describe, expect, it } from 'vitest'; import { ALL_SANDBOX_MODULES } from './module-registry'; import { findLeakedGatedReferences, + findSandboxInternalGlobals, findUnaccountedHostNatives, formatSurfaceEntries, getSandboxEscapeProbe, getSandboxSurface, + SANDBOX_INTERNAL_GLOBALS, } from './sandbox-surface'; describe('sandbox surface', () => { @@ -75,4 +77,12 @@ describe('sandbox', () => { const entries = await getSandboxSurface(); expect(findLeakedGatedReferences(entries)).toEqual([]); }, 20_000); + + // Completeness tripwire: any new sandbox-internal global must be added here deliberately, so its + // safety story (who can reach it, what it can do during discovery) gets reviewed rather than + // slipping in unnoticed. + it('exposes exactly the reviewed set of sandbox-internal globals', async () => { + const entries = await getSandboxSurface(); + expect(findSandboxInternalGlobals(entries)).toEqual([...SANDBOX_INTERNAL_GLOBALS].sort()); + }, 20_000); }); diff --git a/packages/insomnia/src/templating/sandbox/sandbox-surface.ts b/packages/insomnia/src/templating/sandbox/sandbox-surface.ts index cd302c7582..ab276d09c4 100644 --- a/packages/insomnia/src/templating/sandbox/sandbox-surface.ts +++ b/packages/insomnia/src/templating/sandbox/sandbox-surface.ts @@ -207,6 +207,69 @@ export const findLeakedGatedReferences = (entries: SurfaceEntry[]): string[] => .filter(e => !!e.aliasOf && e.aliasOf.startsWith('globalThis.') && (e.root === 'context' || e.root.startsWith('require('))) .map(e => `${e.path} aliases ${e.aliasOf}`); +/** + * The complete, reviewed set of sandbox-internal globals (`in-sandbox-bootstrap.ts`) exposed bare on + * `globalThis` so `RUNNER`/`DESCRIBE_RUNNER` — each a separate `ctx.evalCode` call, so they can only + * share state via `globalThis`, never lexical closure — can reach them. Locked against reassignment, + * but reachable (and callable) by plugin top-level code, same as any other global. `__buildContext` + * in particular returns a context wired to the real host bridge; discovery-time safety comes from + * `runTagInSandbox` substituting a rejecting bridge when `discover: true`, not from hiding this + * global. Adding a new one here forces a human to re-check that story still holds. + */ +export const SANDBOX_INTERNAL_GLOBALS = ['__buildContext', '__describeExports', '__invoke', '__loadPluginEntry', '__require']; + +/** Filters sandbox surface entries down to the bare `globalThis.__*` internals (excluding the `.prototype` sibling lines already in the snapshot). */ +export const findSandboxInternalGlobals = (entries: SurfaceEntry[]): string[] => + entries + .filter(e => e.root === 'globalThis' && /^globalThis\.__[^.]+$/.test(e.path)) + .map(e => e.path.slice('globalThis.'.length)); + +/** + * Tier B-deep reachability probe (discovery-time bridge escape coverage): recursively walks every + * `globalThis`-reachable property (same walk/cycle-detection shape as `buildSurfaceProbeSource`) and + * additionally *invokes* every function found with a couple of generic argument shapes, then walks + * whatever it returns too — the real exploit shape is a bare `globalThis` function (`__buildContext`) + * whose return value exposes further bridge-backed functions several levels down. Meant to run as + * plugin top-level code during a `discover: true` run (no tag ever inserted). The authoritative check + * is the host-side recording bridge seeing zero calls; nothing here reports its own pass/fail — a + * bridge call from inside the sandbox is indistinguishable from any other promise-returning builtin. + */ +export const buildReachabilityProbeSource = (maxDepth = 2): string => ` +(function () { + var seen = []; + var genericArgs = [ + [], + [{ pluginName: 'probe', context: {}, meta: {}, renderPurpose: 'preview', appInfo: {} }], + ]; + function walk(obj, depth) { + if (depth > ${maxDepth}) { return; } + if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) { return; } + if (seen.indexOf(obj) !== -1) { return; } + seen.push(obj); + var names; + try { names = Object.getOwnPropertyNames(obj).sort(); } catch (e) { return; } + for (var i = 0; i < names.length; i++) { + var name = names[i]; + if (name === 'length' || name === 'name' || name === 'prototype') { continue; } + var val; + try { val = obj[name]; } catch (e) { continue; } + if (typeof val === 'function') { + for (var a = 0; a < genericArgs.length; a++) { + try { + var r = val.apply(null, genericArgs[a]); + if (r && typeof r.then === 'function') { r.then(function () {}, function () {}); } + walk(r, 0); + } catch (e) { /* probing only - a thrown arity/shape mismatch is not a finding */ } + } + } + walk(val, depth + 1); + } + } + walk(globalThis, 0); +})(); +module.exports.templateTags = []; +`; + const ESCAPE_PROBE_TAG_NAME = 'escapeProbe'; // Checks whether `this`, Function('return this')(), ambient globals, or the process shim ever