mirror of
https://github.com/Kong/insomnia.git
synced 2026-07-30 09:16:44 -04:00
test(templating): add regression coverage for discovery-time bridge escape
Discovery mode (discover: true) is supposed to be side-effect-free, but runTagInSandbox wires the real host bridge into the VM unconditionally, and plugin top-level code can call globalThis.__buildContext(...) itself to reach it before any tag is ever inserted or run. Adds a fixture-driven regression test for the exploit shape, a stretch-goal walk+invoke reachability probe over every globalThis-reachable function, and a completeness tripwire over the sandbox's internal __-prefixed globals so a new one added later forces review. Both bridge-escape tests currently fail (red) against the unfixed code. Supersedes the untracked POC-discovery-bridge-escape.test.ts PoC, now removed.
This commit is contained in:
@@ -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<string, string>,
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user