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:
Kyle
2026-07-21 13:57:55 -04:00
parent d3eb7daf0b
commit f37977f00d
3 changed files with 132 additions and 1 deletions

View File

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

View File

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

View File

@@ -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