mirror of
https://github.com/Kong/insomnia.git
synced 2026-10-12 02:06:34 +00:00
feat(templating): multi-file plugins via in-sandbox module map (M4) (#10256)
* feat(templating): multi-file plugins via in-sandbox module map (M4)
esbuild is dev-only, so instead of bundling plugins at load time this reads the
plugin's own source files into a module map (readPluginModuleMap, host-side)
and teaches the in-sandbox require to resolve RELATIVE specifiers from that map
(./util, ../x, ./dir -> ./dir/index.js) with a CommonJS cache. Bare specifiers
still route through the grant-gated registry.
Security (the poison guarantee): the host walker reads ONLY within the plugin
directory — node_modules and dot-dirs are skipped, symlink-escapes dropped,
file count/size bounded — so a plugin's own node_modules is never consulted and
a bare require('uuid') always binds the vetted registry copy, never a plugin's
substitute. Plugin source now travels as envelope DATA (moduleFiles) compiled by
the loader via new Function; no host-side eval of plugin code. Single-file
callers keep working via a synthesized one-entry map.
Tests: multi-file-plugins.test.ts (7: relative/nested/dir-index resolution,
hybrid bare-uuid, CJS cache, not-found, syntax-error propagation, bare-vs-map
shadow) + readPluginModuleMap tests (map shape, node_modules-excluded poison
guarantee, custom main, entry-escape). E2E: multi-file resolution + poison
(fake node_modules/uuid ignored). Demo converted to multi-file
(require('./lib/greeting')). PERMISSIONS.md documents multi-file + the
require-vetted-libs-inside-run note.
No esbuild runtime dependency added.
* fix(templating): tighten M4 module-map byte guard and relative-path escape
Address review feedback on the multi-file plugin loader:
- readPluginModuleMap counted source.length (UTF-16 code units), which
undercounts multi-byte UTF-8 payloads against MAX_PLUGIN_MODULE_BYTES, and
the custom-"main" entry fallback was added to the map without counting its
bytes at all. Count Buffer.byteLength(...'utf8') via a shared addModule
helper used by both the walk and the entry fallback.
- __normalizeKey clamped above-root '..' traversal back into the plugin root,
so require('../../util') from a shallow dir could masquerade as an in-plugin
module. Return null for above-root traversal (unresolvable), matching Node
semantics; __resolveRelative propagates it as MODULE_NOT_FOUND.
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
// Multi-file plugin (M4): this entry require()s a sibling file. The sandbox resolves it from the
|
||||
// plugin's own source map — see the `multifileprobe` tag below.
|
||||
// eslint-disable-next-line no-undef -- CJS plugin requiring a sibling file
|
||||
const greeting = require('./lib/greeting');
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
module.exports.templateTags = [
|
||||
{
|
||||
@@ -103,9 +108,18 @@ module.exports.templateTags = [
|
||||
args: [],
|
||||
async run() {
|
||||
// Works because package.json declares the `uuid` grant; uuid is bundled + pinned by Insomnia.
|
||||
// eslint-disable-next-line no-undef, unicorn/prefer-node-protocol -- CJS plugin requiring a vetted registry lib
|
||||
// eslint-disable-next-line no-undef -- CJS plugin requiring a vetted registry lib
|
||||
const uuid = require('uuid');
|
||||
return 'uuid=' + (uuid.validate(uuid.v4()) ? 'ok' : 'bad');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'multifileprobe',
|
||||
displayName: 'Multi-file Probe',
|
||||
description: 'Demos multi-file plugins (M4): index.js requires ./lib/greeting, resolved in-sandbox',
|
||||
args: [],
|
||||
async run() {
|
||||
return greeting.greet('sandbox');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// A sibling module required by index.js — demonstrates multi-file plugins (M4). The sandbox reads
|
||||
// the plugin's own files into a module map and resolves `require('./lib/greeting')` from it; the
|
||||
// plugin's node_modules is never consulted.
|
||||
// eslint-disable-next-line no-undef -- CJS plugin module
|
||||
module.exports.greet = function (who) {
|
||||
return 'hello ' + who + ' from a required sibling file';
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
type: collection.insomnia.rest/5.0
|
||||
schema_version: "5.1"
|
||||
name: Sandbox Multi-file Collection
|
||||
meta:
|
||||
id: wrk_5a4dmf00000000000000000000000001
|
||||
created: 1751800000000
|
||||
modified: 1751800000000
|
||||
collection:
|
||||
- url: http://127.0.0.1:4010/echo
|
||||
name: Multi-file Probe
|
||||
meta:
|
||||
id: req_5a4dmf00000000000000000000000002
|
||||
created: 1751800000001
|
||||
modified: 1751800000001
|
||||
isPrivate: false
|
||||
sortKey: -1751800000001
|
||||
method: GET
|
||||
body:
|
||||
mimeType: text/plain
|
||||
text: |
|
||||
{% multifileprobe %}
|
||||
{% poisonprobe %}
|
||||
headers:
|
||||
- name: Content-Type
|
||||
value: text/plain
|
||||
@@ -548,3 +548,95 @@ test('Template tag sandbox: vetted npm libraries (uuid, ajv) run when declared (
|
||||
// ajv is IN the registry but this plugin didn't declare it — registry presence ≠ grant.
|
||||
await assertTagPreview('{% ajvungranted', "Module 'ajv' not permitted by manifest");
|
||||
});
|
||||
|
||||
// Write an extra file into an already-installed plugin's directory (for multi-file plugins).
|
||||
const writePluginFile = (dataPath: string, pluginName: string, relPath: string, contents: string) => {
|
||||
const abs = path.join(dataPath, 'plugins', pluginName, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, contents);
|
||||
};
|
||||
|
||||
test('Template tag sandbox: multi-file plugins resolve relative requires and ignore their node_modules (M4)', async ({
|
||||
page,
|
||||
app,
|
||||
dataPath,
|
||||
insomnia,
|
||||
}) => {
|
||||
// A multi-file plugin: entry requires a sibling, a nested file, and a vetted registry lib (uuid).
|
||||
writePlugin(
|
||||
dataPath,
|
||||
'insomnia-plugin-multifile',
|
||||
{ permissions: { modules: ['uuid'] } },
|
||||
// Relative requires are top-level (they resolve at both discovery and sandbox execution); the
|
||||
// bare uuid require lives inside run() so plugin discovery (a real nodeRequire in main) doesn't
|
||||
// need it on disk — it's a vetted registry module resolved only inside the sandbox.
|
||||
`var util = require('./util');
|
||||
var helper = require('./nested/helper');
|
||||
module.exports.templateTags = [{ name: 'multifileprobe', displayName: 'multifileprobe', args: [], async run() {
|
||||
var uuid = require('uuid');
|
||||
return util.a() + '|' + helper.b() + '|' + (uuid.validate(uuid.v4()) ? 'id-ok' : 'id-bad');
|
||||
} }];`,
|
||||
);
|
||||
writePluginFile(
|
||||
dataPath,
|
||||
'insomnia-plugin-multifile',
|
||||
'util.js',
|
||||
"module.exports.a = function () { return 'util-a'; };",
|
||||
);
|
||||
writePluginFile(
|
||||
dataPath,
|
||||
'insomnia-plugin-multifile',
|
||||
'nested/helper.js',
|
||||
"var deep = require('../util'); module.exports.b = function () { return 'helper-' + deep.a(); };",
|
||||
);
|
||||
|
||||
// A plugin that ships its OWN node_modules/uuid returning 'evil' — must never be consulted; the
|
||||
// bare require('uuid') resolves to the vetted registry bundle.
|
||||
writePlugin(
|
||||
dataPath,
|
||||
'insomnia-plugin-poison',
|
||||
{ permissions: { modules: ['uuid'] } },
|
||||
// Top-level require('uuid') deliberately resolves to this plugin's OWN fake node_modules/uuid at
|
||||
// discovery time (so the plugin loads), but the sandbox — which never reads node_modules — binds
|
||||
// the real vetted uuid from the registry. The two disagreeing is exactly the poison guarantee.
|
||||
`var uuid = require('uuid');
|
||||
module.exports.templateTags = [{ name: 'poisonprobe', displayName: 'poisonprobe', args: [], async run() {
|
||||
var id = uuid.v4();
|
||||
return id === 'evil' ? 'POISONED' : (uuid.validate(id) ? 'poison-real' : 'poison-bad');
|
||||
} }];`,
|
||||
);
|
||||
writePluginFile(
|
||||
dataPath,
|
||||
'insomnia-plugin-poison',
|
||||
'node_modules/uuid/index.js',
|
||||
"module.exports = { v4: function () { return 'evil'; }, validate: function () { return true; } };",
|
||||
);
|
||||
|
||||
await clearPluginToast(page);
|
||||
await page.evaluate(() => (window as any).main.plugins.reloadPlugins());
|
||||
|
||||
const fixture = await loadFixture('sandbox-multifile-collection.yaml');
|
||||
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), fixture);
|
||||
await page.getByLabel('Import').click();
|
||||
await page.locator('[data-test-id="import-from-clipboard"]').click();
|
||||
await page.getByRole('button', { name: 'Scan' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
|
||||
|
||||
await enableSandbox(page);
|
||||
|
||||
await insomnia.navigationSidebar.clickRequestOrFolder('Multi-file Probe');
|
||||
await page.getByText('Body', { exact: true }).click();
|
||||
|
||||
const assertTagPreview = async (tagPrefix: string, expected: string) => {
|
||||
await page.locator(`[data-template^="${tagPrefix}"]`).click();
|
||||
const modal = page.getByRole('dialog');
|
||||
await expect.soft(modal.getByLabel('Live Preview')).toContainText(expected);
|
||||
await modal.getByRole('button', { name: 'Done' }).click();
|
||||
await expect.soft(modal).toBeHidden();
|
||||
};
|
||||
|
||||
// Relative requires (sibling + nested + ../) all resolve, and the bare uuid require hits the registry.
|
||||
await assertTagPreview('{% multifileprobe', 'util-a|helper-util-a|id-ok');
|
||||
// The plugin's own node_modules/uuid ('evil') is never consulted — the real vetted uuid runs.
|
||||
await assertTagPreview('{% poisonprobe', 'poison-real');
|
||||
});
|
||||
|
||||
@@ -40,7 +40,9 @@
|
||||
"sandbox:vendored:generate": "esr ./scripts/generate-sandbox-vendored.ts",
|
||||
"sandbox:vendored:guardrail": "esr ./scripts/check-sandbox-vendor-guardrail.ts",
|
||||
"sandbox:vendored:upgrade": "esr ./scripts/upgrade-sandbox-vendored.ts",
|
||||
"sandbox:vendored:test-regression": "vitest run --config vitest.sandbox-vendored-regression.config.ts"
|
||||
"sandbox:vendored:test-regression": "vitest run --config vitest.sandbox-vendored-regression.config.ts",
|
||||
"sandbox:surface": "vitest run src/templating/sandbox/print-sandbox-surface.test.ts",
|
||||
"sandbox:surface:test": "vitest run src/templating/sandbox/sandbox-surface.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apideck/better-ajv-errors": "^0.3.6",
|
||||
|
||||
@@ -47,9 +47,76 @@ import {
|
||||
_testOnlyResetMigrationWarnings,
|
||||
getPluginEntrySource,
|
||||
maybeWarnMissingManifest,
|
||||
readPluginModuleMap,
|
||||
runPluginTagInSandbox,
|
||||
} from '../templating-worker-database';
|
||||
|
||||
describe('readPluginModuleMap (M4 multi-file plugin reader)', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-plugin-map-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads the plugin dir into a relative-keyed module map (entry + siblings + nested)', () => {
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ main: 'index.js' }));
|
||||
fs.writeFileSync(path.join(dir, 'index.js'), "require('./util');");
|
||||
fs.writeFileSync(path.join(dir, 'util.js'), 'module.exports = {};');
|
||||
fs.mkdirSync(path.join(dir, 'nested'));
|
||||
fs.writeFileSync(path.join(dir, 'nested', 'helper.js'), 'module.exports = {};');
|
||||
const { moduleFiles, entryModuleKey } = readPluginModuleMap({ directory: dir, name: 'p' });
|
||||
expect(entryModuleKey).toBe('index.js');
|
||||
expect(Object.keys(moduleFiles).sort()).toEqual(['index.js', 'nested/helper.js', 'util.js']);
|
||||
});
|
||||
|
||||
it("never reads the plugin's own node_modules (the poison guarantee)", () => {
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ main: 'index.js' }));
|
||||
fs.writeFileSync(path.join(dir, 'index.js'), "require('uuid');");
|
||||
fs.mkdirSync(path.join(dir, 'node_modules', 'uuid'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'node_modules', 'uuid', 'index.js'), "module.exports.v4 = () => 'evil';");
|
||||
const { moduleFiles } = readPluginModuleMap({ directory: dir, name: 'p' });
|
||||
expect(Object.keys(moduleFiles)).toEqual(['index.js']);
|
||||
expect(Object.keys(moduleFiles).some(k => k.includes('node_modules'))).toBe(false);
|
||||
});
|
||||
|
||||
it('honors a custom package.json "main" as the entry key', () => {
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ main: 'src/entry.js' }));
|
||||
fs.mkdirSync(path.join(dir, 'src'));
|
||||
fs.writeFileSync(path.join(dir, 'src', 'entry.js'), 'module.exports = {};');
|
||||
const { entryModuleKey, moduleFiles } = readPluginModuleMap({ directory: dir, name: 'p' });
|
||||
expect(entryModuleKey).toBe('src/entry.js');
|
||||
expect(moduleFiles['src/entry.js']).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects an entry that escapes the plugin directory', () => {
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ main: '../../../../etc/passwd' }));
|
||||
expect(() => readPluginModuleMap({ directory: dir, name: 'p' })).toThrow(/escapes plugin directory/);
|
||||
});
|
||||
|
||||
it('rejects a plugin with more than MAX_PLUGIN_MODULE_FILES source files', () => {
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ main: 'index.js' }));
|
||||
fs.writeFileSync(path.join(dir, 'index.js'), "module.exports = {};");
|
||||
for (let i = 0; i < 500; i++) {
|
||||
fs.writeFileSync(path.join(dir, `f${i}.js`), 'module.exports = {};');
|
||||
}
|
||||
expect(() => readPluginModuleMap({ directory: dir, name: 'p' })).toThrow(/too many source files/);
|
||||
});
|
||||
|
||||
it('reads a custom "main" whose name shadows an Object.prototype member', () => {
|
||||
// An extensionless "main" like "toString" collides with Object.prototype.toString: an `in`
|
||||
// check against a plain {} object would see it as already present and skip reading the real
|
||||
// file, silently dropping the plugin's entry content instead of loading it.
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ main: 'toString' }));
|
||||
fs.writeFileSync(path.join(dir, 'toString'), 'module.exports = { real: true };');
|
||||
const { moduleFiles, entryModuleKey } = readPluginModuleMap({ directory: dir, name: 'p' });
|
||||
expect(entryModuleKey).toBe('toString');
|
||||
expect(Object.prototype.hasOwnProperty.call(moduleFiles, 'toString')).toBe(true);
|
||||
expect(moduleFiles['toString']).toBe('module.exports = { real: true };');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeWarnMissingManifest (P1 migration warning)', () => {
|
||||
beforeEach(() => {
|
||||
sentToasts.length = 0;
|
||||
|
||||
@@ -128,6 +128,92 @@ export const getPluginEntrySource = ({ directory, name }: { directory: string; n
|
||||
}
|
||||
};
|
||||
|
||||
// True when `target` is `base` itself or lives inside it (after both are realpath-resolved).
|
||||
const isContainedIn = (base: string, target: string): boolean => {
|
||||
const rel = path.relative(base, target);
|
||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
||||
};
|
||||
|
||||
// Bounds so a pathological plugin directory can't exhaust memory when read into the module map.
|
||||
const MAX_PLUGIN_MODULE_FILES = 500;
|
||||
const MAX_PLUGIN_MODULE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
// Read a plugin's own source files into a module map for the sandbox's relative-require loader (M4).
|
||||
// Walks ONLY within the plugin's directory — `node_modules` and dot-dirs are skipped and symlinked
|
||||
// files whose real path escapes the directory are dropped — so the plugin's own dependencies are
|
||||
// never loaded (bare `require('x')` always routes to the vetted registry, never the plugin's copy).
|
||||
// Bundle (first-party) plugins have no directory and stay single-file.
|
||||
export const readPluginModuleMap = ({
|
||||
directory,
|
||||
name,
|
||||
}: {
|
||||
directory: string;
|
||||
name: string;
|
||||
}): { moduleFiles: Record<string, string>; entryModuleKey: string } => {
|
||||
if (!directory) {
|
||||
return { moduleFiles: { 'index.js': getPluginEntrySource({ directory: '', name }) }, entryModuleKey: 'index.js' };
|
||||
}
|
||||
try {
|
||||
const base = fs.realpathSync(path.resolve(directory));
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(base, 'package.json'), 'utf8'));
|
||||
const entryAbs = path.resolve(base, pkg.main || 'index.js');
|
||||
// Check the plain resolved path first (catches traversal even if the target doesn't exist),
|
||||
// then the realpath (catches a symlinked entry pointing outside the dir).
|
||||
if (!isContainedIn(base, entryAbs) || !isContainedIn(base, fs.realpathSync(entryAbs))) {
|
||||
throw new Error(`plugin entry point escapes plugin directory: ${pkg.main}`);
|
||||
}
|
||||
const toKey = (abs: string) => path.relative(base, abs).split(path.sep).join('/');
|
||||
const moduleFiles: Record<string, string> = {};
|
||||
let totalBytes = 0;
|
||||
let fileCount = 0;
|
||||
// Count UTF-8 bytes (not source.length, which is UTF-16 code units and undercounts multi-byte
|
||||
// characters) so the limit reflects the real payload shipped into the sandbox envelope.
|
||||
const addModule = (key: string, source: string): void => {
|
||||
totalBytes += Buffer.byteLength(source, 'utf8');
|
||||
if (totalBytes > MAX_PLUGIN_MODULE_BYTES) {
|
||||
throw new Error('plugin source exceeds the sandbox size limit');
|
||||
}
|
||||
moduleFiles[key] = source;
|
||||
fileCount++;
|
||||
};
|
||||
const walk = (dir: string): void => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(abs);
|
||||
continue;
|
||||
}
|
||||
// package.json is plugin metadata, not a requireable module — keep it out of the map.
|
||||
if (!entry.isFile() || entry.name === 'package.json' || !/\.(js|json)$/.test(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
if (!isContainedIn(base, fs.realpathSync(abs))) {
|
||||
continue; // symlink whose target escapes the plugin dir
|
||||
}
|
||||
if (fileCount >= MAX_PLUGIN_MODULE_FILES) {
|
||||
throw new Error(`plugin has too many source files (> ${MAX_PLUGIN_MODULE_FILES})`);
|
||||
}
|
||||
addModule(toKey(abs), fs.readFileSync(abs, 'utf8'));
|
||||
}
|
||||
};
|
||||
walk(base);
|
||||
const entryModuleKey = toKey(entryAbs);
|
||||
if (!Object.prototype.hasOwnProperty.call(moduleFiles, entryModuleKey)) {
|
||||
// The entry may sit outside the walk (e.g. a custom package.json "main"); still count it
|
||||
// against the size limit rather than adding it unbounded.
|
||||
addModule(entryModuleKey, fs.readFileSync(entryAbs, 'utf8'));
|
||||
}
|
||||
return { moduleFiles, entryModuleKey };
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to load sandbox source for plugin '${name}': ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// (plugin name, module) pairs we've already toasted for this session — so a repeat denial for the
|
||||
// SAME missing module doesn't re-toast, but a plugin missing two different grants still gets a
|
||||
// distinct hint for each one it actually hits (P1).
|
||||
@@ -174,8 +260,12 @@ export const maybeWarnMissingManifest = (
|
||||
// Execute a plugin template tag inside the QuickJS-WASM sandbox instead of directly in the main
|
||||
// process. The host bridge reuses the existing pluginToMainAPI handlers verbatim, plus a util.render
|
||||
// handler that recurses through main templating. Gated behind the templateTagSandboxEnabled setting.
|
||||
// The plugin's own source, either a single entry string (single-file plugins/tests) or a full
|
||||
// module map read from disk (M4 multi-file plugins).
|
||||
type PluginModuleSource = string | { moduleFiles: Record<string, string>; entryModuleKey: string };
|
||||
|
||||
export const runPluginTagInSandbox = async (
|
||||
pluginSource: string,
|
||||
pluginModule: PluginModuleSource,
|
||||
body: {
|
||||
args: any[];
|
||||
pluginName: string;
|
||||
@@ -197,6 +287,10 @@ export const runPluginTagInSandbox = async (
|
||||
const { pluginName, tagName, args, context: originContext } = body;
|
||||
const { meta, renderPurpose, context } = originContext;
|
||||
const capabilities = grantedCapabilities ?? [...TEMPLATE_TAG_BASELINE_CAPABILITIES];
|
||||
const { moduleFiles, entryModuleKey } =
|
||||
typeof pluginModule === 'string'
|
||||
? { moduleFiles: { 'index.js': pluginModule }, entryModuleKey: 'index.js' }
|
||||
: pluginModule;
|
||||
// Gate the handler map by capability before building the bridge: an ungranted path rejects at the
|
||||
// bridge with a message naming the missing capability, rather than silently running.
|
||||
const bridge = createMapBridge(
|
||||
@@ -214,7 +308,6 @@ export const runPluginTagInSandbox = async (
|
||||
),
|
||||
);
|
||||
return runTagInSandbox({
|
||||
pluginSource,
|
||||
tagName,
|
||||
bridge,
|
||||
// node:crypto is synchronous and available in main, so back the sandbox's require('crypto')
|
||||
@@ -243,6 +336,8 @@ export const runPluginTagInSandbox = async (
|
||||
renderDepth: 0,
|
||||
grantedModules: grantedModules ?? [...TEMPLATE_TAG_BASELINE_MODULES],
|
||||
grantedCapabilities: capabilities,
|
||||
moduleFiles,
|
||||
entryModuleKey,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -473,7 +568,7 @@ const pluginToMainAPI: Record<PluginToMainAPIPaths, (...args: any[]) => Promise<
|
||||
const { ALL_SANDBOX_MODULES } = await import('../templating/sandbox/module-registry');
|
||||
// Bundle plugins are first-party and trusted: grant every module + capability.
|
||||
return runPluginTagInSandbox(
|
||||
getPluginEntrySource({ directory: '', name: pluginName }),
|
||||
readPluginModuleMap({ directory: '', name: pluginName }),
|
||||
body,
|
||||
[...ALL_SANDBOX_MODULES],
|
||||
[...ALL_CAPABILITIES],
|
||||
@@ -521,7 +616,7 @@ const pluginToMainAPI: Record<PluginToMainAPIPaths, (...args: any[]) => Promise<
|
||||
);
|
||||
try {
|
||||
return await runPluginTagInSandbox(
|
||||
getPluginEntrySource({ directory: targetTag.plugin.directory, name: pluginName }),
|
||||
readPluginModuleMap({ directory: targetTag.plugin.directory, name: pluginName }),
|
||||
body,
|
||||
resolveTemplateTagModules(targetTag.plugin.permissions?.modules),
|
||||
resolveTemplateTagCapabilities(targetTag.plugin.permissions?.capabilities),
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
When the **Run template tags in sandbox** setting is on (Preferences → Scripting), a plugin's
|
||||
template-tag `run()` executes inside an isolated QuickJS sandbox. Inside that sandbox a plugin can
|
||||
only reach what it is *granted* — a default-deny model with two independent axes declared in the
|
||||
only reach what it is _granted_ — a default-deny model with two independent axes declared in the
|
||||
plugin's `package.json`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"insomnia": {
|
||||
"permissions": {
|
||||
"modules": ["events"], // what require(x) may return
|
||||
"capabilities": ["network"] // which host bridge groups context.* may call
|
||||
}
|
||||
}
|
||||
"modules": ["events"], // what require(x) may return
|
||||
"capabilities": ["network"], // which host bridge groups context.* may call
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,7 +41,7 @@ by Insomnia (a pure-JS reimplementation or a host-backed shim), never the raw No
|
||||
`<name>.regression.test.ts` suite before you commit.
|
||||
- `require('X')` where `X` isn't granted → **`Module 'X' not permitted by manifest`**.
|
||||
- `require('X')` where `X` is granted but Insomnia doesn't ship it → **`Module 'X' not available in
|
||||
sandbox`** (ask for it to be added to the registry).
|
||||
sandbox`** (ask for it to be added to the registry).
|
||||
|
||||
## Axis 2 — `capabilities`
|
||||
|
||||
@@ -49,16 +49,16 @@ Host-side actions your tag may perform through `context.*`. A capability you don
|
||||
branch of `context` is simply **absent** (`context.network === undefined`), so you can feature-detect
|
||||
and degrade gracefully.
|
||||
|
||||
| Capability | Grants | Baseline? |
|
||||
|------------|--------|-----------|
|
||||
| `render` | `context.util.render` | ✅ |
|
||||
| `models.read` | read-only `context.util.models.*` lookups | ✅ |
|
||||
| `util` | `context.util.nodeOS` / `decode` / `encode` | ✅ |
|
||||
| `crypto` | `require('crypto')` host functions | ✅ |
|
||||
| `network` | `context.network.*` | — declare it |
|
||||
| `storage` | `context.store.*` | — declare it |
|
||||
| `fs-read` | `context.util.readFile` | — declare it |
|
||||
| `app` | `context.app.*`, `context.util.openInBrowser` | — declare it |
|
||||
| Capability | Grants | Baseline? |
|
||||
| ------------- | --------------------------------------------- | ------------ |
|
||||
| `render` | `context.util.render` | ✅ |
|
||||
| `models.read` | read-only `context.util.models.*` lookups | ✅ |
|
||||
| `util` | `context.util.nodeOS` / `decode` / `encode` | ✅ |
|
||||
| `crypto` | `require('crypto')` host functions | ✅ |
|
||||
| `network` | `context.network.*` | — declare it |
|
||||
| `storage` | `context.store.*` | — declare it |
|
||||
| `fs-read` | `context.util.readFile` | — declare it |
|
||||
| `app` | `context.app.*`, `context.util.openInBrowser` | — declare it |
|
||||
|
||||
`credentials` (cloud-provider credential read/write) is **reserved for first-party bundle plugins**
|
||||
and cannot be granted to a community template-tag plugin, even if declared — it is above the
|
||||
@@ -70,6 +70,21 @@ A plugin that declares **no** `permissions` block runs on the baseline grant. If
|
||||
a non-baseline module you'll see a one-time notification naming the exact grant to add. Add the
|
||||
`insomnia.permissions` block above and reload the plugin.
|
||||
|
||||
## Multi-file plugins
|
||||
|
||||
A plugin can be split across files: `require('./util')`, `require('../shared')`, and
|
||||
`require('./lib')` (→ `./lib/index.js`) all resolve from the plugin's own source, read from within
|
||||
the plugin directory. The plugin's **own `node_modules` is never consulted** — a bare
|
||||
`require('uuid')` always resolves to the vetted registry copy, so a plugin can't ship a substitute
|
||||
implementation of a granted module. Only `.js`/`.json` files inside the plugin directory are
|
||||
loaded (`node_modules` and dot-directories are skipped).
|
||||
|
||||
> **`require` a vetted lib inside `run()`, not at top level.** Insomnia still discovers a plugin's
|
||||
> tags by loading its entry in the host process, so a **top-level** `require('uuid')`/`require('ajv')`
|
||||
> fails to resolve there (those live only in the sandbox registry, not on disk next to the plugin).
|
||||
> Require them from inside the tag's `run()` — they resolve when the tag executes in the sandbox.
|
||||
> Relative requires (`./util`) are fine at top level.
|
||||
|
||||
## Notes
|
||||
|
||||
- Bundle (first-party) plugins are trusted and receive all modules + capabilities.
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`sandbox surface > matches surface snapshot 1`] = `
|
||||
[
|
||||
"globalThis.AggregateError: function(2)[native]",
|
||||
"globalThis.AggregateError.prototype: object",
|
||||
"globalThis.Array: function(1)[native]",
|
||||
"globalThis.Array.from: function(1)[native]",
|
||||
"globalThis.Array.isArray: function(1)[native]",
|
||||
"globalThis.Array.of: function(0)[native]",
|
||||
"globalThis.Array.prototype: object",
|
||||
"globalThis.ArrayBuffer: function(1)[native]",
|
||||
"globalThis.ArrayBuffer.isView: function(1)[native]",
|
||||
"globalThis.ArrayBuffer.prototype: object",
|
||||
"globalThis.BigInt: function(1)[native]",
|
||||
"globalThis.BigInt.asIntN: function(2)[native]",
|
||||
"globalThis.BigInt.asUintN: function(2)[native]",
|
||||
"globalThis.BigInt.prototype: object",
|
||||
"globalThis.BigInt64Array: function(3)[native]",
|
||||
"globalThis.BigInt64Array.BYTES_PER_ELEMENT: number",
|
||||
"globalThis.BigInt64Array.prototype: object",
|
||||
"globalThis.BigUint64Array: function(3)[native]",
|
||||
"globalThis.BigUint64Array.BYTES_PER_ELEMENT: number",
|
||||
"globalThis.BigUint64Array.prototype: object",
|
||||
"globalThis.Boolean: function(1)[native]",
|
||||
"globalThis.Boolean.prototype: object",
|
||||
"globalThis.Buffer: object",
|
||||
"globalThis.Buffer.alloc: function(2)",
|
||||
"globalThis.Buffer.alloc.prototype: object",
|
||||
"globalThis.Buffer.concat: function(1)",
|
||||
"globalThis.Buffer.concat.prototype: object",
|
||||
"globalThis.Buffer.from: function(2)",
|
||||
"globalThis.Buffer.from.prototype: object",
|
||||
"globalThis.Buffer.isBuffer: function(1)",
|
||||
"globalThis.Buffer.isBuffer.prototype: object",
|
||||
"globalThis.DataView: function(1)[native]",
|
||||
"globalThis.DataView.prototype: object",
|
||||
"globalThis.Date: function(7)[native]",
|
||||
"globalThis.Date.UTC: function(7)[native]",
|
||||
"globalThis.Date.now: function(0)[native]",
|
||||
"globalThis.Date.parse: function(1)[native]",
|
||||
"globalThis.Date.prototype: object",
|
||||
"globalThis.Error: function(1)[native]",
|
||||
"globalThis.Error.isError: function(1)[native]",
|
||||
"globalThis.Error.prototype: object",
|
||||
"globalThis.EvalError: function(1)[native]",
|
||||
"globalThis.EvalError.prototype: object",
|
||||
"globalThis.FinalizationRegistry: function(1)[native]",
|
||||
"globalThis.FinalizationRegistry.prototype: object",
|
||||
"globalThis.Float16Array: function(3)[native]",
|
||||
"globalThis.Float16Array.BYTES_PER_ELEMENT: number",
|
||||
"globalThis.Float16Array.prototype: object",
|
||||
"globalThis.Float32Array: function(3)[native]",
|
||||
"globalThis.Float32Array.BYTES_PER_ELEMENT: number",
|
||||
"globalThis.Float32Array.prototype: object",
|
||||
"globalThis.Float64Array: function(3)[native]",
|
||||
"globalThis.Float64Array.BYTES_PER_ELEMENT: number",
|
||||
"globalThis.Float64Array.prototype: object",
|
||||
"globalThis.Function: function(1)[native]",
|
||||
"globalThis.Function.prototype: function(0)[native]",
|
||||
"globalThis.INSOMNIA_TEMPLATE_SANDBOX: boolean",
|
||||
"globalThis.Infinity: number",
|
||||
"globalThis.Int16Array: function(3)[native]",
|
||||
"globalThis.Int16Array.BYTES_PER_ELEMENT: number",
|
||||
"globalThis.Int16Array.prototype: object",
|
||||
"globalThis.Int32Array: function(3)[native]",
|
||||
"globalThis.Int32Array.BYTES_PER_ELEMENT: number",
|
||||
"globalThis.Int32Array.prototype: object",
|
||||
"globalThis.Int8Array: function(3)[native]",
|
||||
"globalThis.Int8Array.BYTES_PER_ELEMENT: number",
|
||||
"globalThis.Int8Array.prototype: object",
|
||||
"globalThis.InternalError: function(1)[native]",
|
||||
"globalThis.InternalError.prototype: object",
|
||||
"globalThis.Iterator: function(0)[native]",
|
||||
"globalThis.Iterator.concat: function(0)[native]",
|
||||
"globalThis.Iterator.from: function(1)[native]",
|
||||
"globalThis.Iterator.prototype: object",
|
||||
"globalThis.JSON: object",
|
||||
"globalThis.JSON.parse: function(2)[native]",
|
||||
"globalThis.JSON.stringify: function(3)[native]",
|
||||
"globalThis.Map: function(0)[native]",
|
||||
"globalThis.Map.groupBy: function(2)[native]",
|
||||
"globalThis.Map.prototype: object",
|
||||
"globalThis.Math: object",
|
||||
"globalThis.Math.E: number",
|
||||
"globalThis.Math.LN10: number",
|
||||
"globalThis.Math.LN2: number",
|
||||
"globalThis.Math.LOG10E: number",
|
||||
"globalThis.Math.LOG2E: number",
|
||||
"globalThis.Math.PI: number",
|
||||
"globalThis.Math.SQRT1_2: number",
|
||||
"globalThis.Math.SQRT2: number",
|
||||
"globalThis.Math.abs: function(1)[native]",
|
||||
"globalThis.Math.acos: function(1)[native]",
|
||||
"globalThis.Math.acosh: function(1)[native]",
|
||||
"globalThis.Math.asin: function(1)[native]",
|
||||
"globalThis.Math.asinh: function(1)[native]",
|
||||
"globalThis.Math.atan: function(1)[native]",
|
||||
"globalThis.Math.atan2: function(2)[native]",
|
||||
"globalThis.Math.atanh: function(1)[native]",
|
||||
"globalThis.Math.cbrt: function(1)[native]",
|
||||
"globalThis.Math.ceil: function(1)[native]",
|
||||
"globalThis.Math.clz32: function(1)[native]",
|
||||
"globalThis.Math.cos: function(1)[native]",
|
||||
"globalThis.Math.cosh: function(1)[native]",
|
||||
"globalThis.Math.exp: function(1)[native]",
|
||||
"globalThis.Math.expm1: function(1)[native]",
|
||||
"globalThis.Math.f16round: function(1)[native]",
|
||||
"globalThis.Math.floor: function(1)[native]",
|
||||
"globalThis.Math.fround: function(1)[native]",
|
||||
"globalThis.Math.hypot: function(2)[native]",
|
||||
"globalThis.Math.imul: function(2)[native]",
|
||||
"globalThis.Math.log: function(1)[native]",
|
||||
"globalThis.Math.log10: function(1)[native]",
|
||||
"globalThis.Math.log1p: function(1)[native]",
|
||||
"globalThis.Math.log2: function(1)[native]",
|
||||
"globalThis.Math.max: function(2)[native]",
|
||||
"globalThis.Math.min: function(2)[native]",
|
||||
"globalThis.Math.pow: function(2)[native]",
|
||||
"globalThis.Math.random: function(0)[native]",
|
||||
"globalThis.Math.round: function(1)[native]",
|
||||
"globalThis.Math.sign: function(1)[native]",
|
||||
"globalThis.Math.sin: function(1)[native]",
|
||||
"globalThis.Math.sinh: function(1)[native]",
|
||||
"globalThis.Math.sqrt: function(1)[native]",
|
||||
"globalThis.Math.sumPrecise: function(1)[native]",
|
||||
"globalThis.Math.tan: function(1)[native]",
|
||||
"globalThis.Math.tanh: function(1)[native]",
|
||||
"globalThis.Math.trunc: function(1)[native]",
|
||||
"globalThis.NaN: number",
|
||||
"globalThis.Number: function(1)[native]",
|
||||
"globalThis.Number.EPSILON: number",
|
||||
"globalThis.Number.MAX_SAFE_INTEGER: number",
|
||||
"globalThis.Number.MAX_VALUE: number",
|
||||
"globalThis.Number.MIN_SAFE_INTEGER: number",
|
||||
"globalThis.Number.MIN_VALUE: number",
|
||||
"globalThis.Number.NEGATIVE_INFINITY: number",
|
||||
"globalThis.Number.NaN: number",
|
||||
"globalThis.Number.POSITIVE_INFINITY: number",
|
||||
"globalThis.Number.isFinite: function(1)[native]",
|
||||
"globalThis.Number.isInteger: function(1)[native]",
|
||||
"globalThis.Number.isNaN: function(1)[native]",
|
||||
"globalThis.Number.isSafeInteger: function(1)[native]",
|
||||
"globalThis.Number.parseFloat: function(1)[native]",
|
||||
"globalThis.Number.parseInt: function(2)[native]",
|
||||
"globalThis.Number.prototype: object",
|
||||
"globalThis.Object: function(1)[native]",
|
||||
"globalThis.Object.assign: function(2)[native]",
|
||||
"globalThis.Object.create: function(2)[native]",
|
||||
"globalThis.Object.defineProperties: function(2)[native]",
|
||||
"globalThis.Object.defineProperty: function(3)[native]",
|
||||
"globalThis.Object.entries: function(1)[native]",
|
||||
"globalThis.Object.freeze: function(1)[native]",
|
||||
"globalThis.Object.fromEntries: function(1)[native]",
|
||||
"globalThis.Object.getOwnPropertyDescriptor: function(2)[native]",
|
||||
"globalThis.Object.getOwnPropertyDescriptors: function(1)[native]",
|
||||
"globalThis.Object.getOwnPropertyNames: function(1)[native]",
|
||||
"globalThis.Object.getOwnPropertySymbols: function(1)[native]",
|
||||
"globalThis.Object.getPrototypeOf: function(1)[native]",
|
||||
"globalThis.Object.groupBy: function(2)[native]",
|
||||
"globalThis.Object.hasOwn: function(2)[native]",
|
||||
"globalThis.Object.is: function(2)[native]",
|
||||
"globalThis.Object.isExtensible: function(1)[native]",
|
||||
"globalThis.Object.isFrozen: function(1)[native]",
|
||||
"globalThis.Object.isSealed: function(1)[native]",
|
||||
"globalThis.Object.keys: function(1)[native]",
|
||||
"globalThis.Object.preventExtensions: function(1)[native]",
|
||||
"globalThis.Object.prototype: object",
|
||||
"globalThis.Object.seal: function(1)[native]",
|
||||
"globalThis.Object.setPrototypeOf: function(2)[native]",
|
||||
"globalThis.Object.values: function(1)[native]",
|
||||
"globalThis.Promise: function(1)[native]",
|
||||
"globalThis.Promise.all: function(1)[native]",
|
||||
"globalThis.Promise.allSettled: function(1)[native]",
|
||||
"globalThis.Promise.any: function(1)[native]",
|
||||
"globalThis.Promise.prototype: object",
|
||||
"globalThis.Promise.race: function(1)[native]",
|
||||
"globalThis.Promise.reject: function(1)[native]",
|
||||
"globalThis.Promise.resolve: function(1)[native]",
|
||||
"globalThis.Promise.try: function(1)[native]",
|
||||
"globalThis.Promise.withResolvers: function(0)[native]",
|
||||
"globalThis.Proxy: function(2)[native]",
|
||||
"globalThis.Proxy.revocable: function(2)[native]",
|
||||
"globalThis.RangeError: function(1)[native]",
|
||||
"globalThis.RangeError.prototype: object",
|
||||
"globalThis.ReferenceError: function(1)[native]",
|
||||
"globalThis.ReferenceError.prototype: object",
|
||||
"globalThis.Reflect: object",
|
||||
"globalThis.Reflect.apply: function(3)[native]",
|
||||
"globalThis.Reflect.construct: function(2)[native]",
|
||||
"globalThis.Reflect.defineProperty: function(3)[native]",
|
||||
"globalThis.Reflect.deleteProperty: function(2)[native]",
|
||||
"globalThis.Reflect.get: function(2)[native]",
|
||||
"globalThis.Reflect.getOwnPropertyDescriptor: function(2)[native]",
|
||||
"globalThis.Reflect.getPrototypeOf: function(1)[native]",
|
||||
"globalThis.Reflect.has: function(2)[native]",
|
||||
"globalThis.Reflect.isExtensible: function(1)[native]",
|
||||
"globalThis.Reflect.ownKeys: function(1)[native]",
|
||||
"globalThis.Reflect.preventExtensions: function(1)[native]",
|
||||
"globalThis.Reflect.set: function(3)[native]",
|
||||
"globalThis.Reflect.setPrototypeOf: function(2)[native]",
|
||||
"globalThis.RegExp: function(2)[native]",
|
||||
"globalThis.RegExp.escape: function(1)[native]",
|
||||
"globalThis.RegExp.prototype: object",
|
||||
"globalThis.Set: function(0)[native]",
|
||||
"globalThis.Set.groupBy: function(2)[native]",
|
||||
"globalThis.Set.prototype: object",
|
||||
"globalThis.SharedArrayBuffer: function(1)[native]",
|
||||
"globalThis.SharedArrayBuffer.prototype: object",
|
||||
"globalThis.String: function(1)[native]",
|
||||
"globalThis.String.fromCharCode: function(1)[native]",
|
||||
"globalThis.String.fromCodePoint: function(1)[native]",
|
||||
"globalThis.String.prototype: object",
|
||||
"globalThis.String.raw: function(1)[native]",
|
||||
"globalThis.Symbol: function(0)[native]",
|
||||
"globalThis.Symbol.asyncIterator: symbol",
|
||||
"globalThis.Symbol.for: function(1)[native]",
|
||||
"globalThis.Symbol.hasInstance: symbol",
|
||||
"globalThis.Symbol.isConcatSpreadable: symbol",
|
||||
"globalThis.Symbol.iterator: symbol",
|
||||
"globalThis.Symbol.keyFor: function(1)[native]",
|
||||
"globalThis.Symbol.match: symbol",
|
||||
"globalThis.Symbol.matchAll: symbol",
|
||||
"globalThis.Symbol.prototype: object",
|
||||
"globalThis.Symbol.replace: symbol",
|
||||
"globalThis.Symbol.search: symbol",
|
||||
"globalThis.Symbol.species: symbol",
|
||||
"globalThis.Symbol.split: symbol",
|
||||
"globalThis.Symbol.toPrimitive: symbol",
|
||||
"globalThis.Symbol.toStringTag: symbol",
|
||||
"globalThis.Symbol.unscopables: symbol",
|
||||
"globalThis.SyntaxError: function(1)[native]",
|
||||
"globalThis.SyntaxError.prototype: object",
|
||||
"globalThis.TextDecoder: function(0)",
|
||||
"globalThis.TextDecoder.prototype: object",
|
||||
"globalThis.TextEncoder: function(0)",
|
||||
"globalThis.TextEncoder.prototype: object",
|
||||
"globalThis.TypeError: function(1)[native]",
|
||||
"globalThis.TypeError.prototype: object",
|
||||
"globalThis.URIError: function(1)[native]",
|
||||
"globalThis.URIError.prototype: object",
|
||||
"globalThis.URL: function(2)",
|
||||
"globalThis.URL.prototype: object",
|
||||
"globalThis.URLSearchParams: function(1)",
|
||||
"globalThis.URLSearchParams.prototype: object",
|
||||
"globalThis.Uint16Array: function(3)[native]",
|
||||
"globalThis.Uint16Array.BYTES_PER_ELEMENT: number",
|
||||
"globalThis.Uint16Array.prototype: object",
|
||||
"globalThis.Uint32Array: function(3)[native]",
|
||||
"globalThis.Uint32Array.BYTES_PER_ELEMENT: number",
|
||||
"globalThis.Uint32Array.prototype: object",
|
||||
"globalThis.Uint8Array: function(3)[native]",
|
||||
"globalThis.Uint8Array.BYTES_PER_ELEMENT: number",
|
||||
"globalThis.Uint8Array.prototype: object",
|
||||
"globalThis.Uint8ClampedArray: function(3)[native]",
|
||||
"globalThis.Uint8ClampedArray.BYTES_PER_ELEMENT: number",
|
||||
"globalThis.Uint8ClampedArray.prototype: object",
|
||||
"globalThis.WeakMap: function(0)[native]",
|
||||
"globalThis.WeakMap.prototype: object",
|
||||
"globalThis.WeakRef: function(1)[native]",
|
||||
"globalThis.WeakRef.prototype: object",
|
||||
"globalThis.WeakSet: function(0)[native]",
|
||||
"globalThis.WeakSet.prototype: object",
|
||||
"globalThis.__buildContext: function(1)",
|
||||
"globalThis.__buildContext.prototype: object",
|
||||
"globalThis.__invoke: function(0)",
|
||||
"globalThis.__invoke.prototype: object",
|
||||
"globalThis.__loadPluginEntry: function(0)",
|
||||
"globalThis.__loadPluginEntry.prototype: object",
|
||||
"globalThis.__require: function(1)",
|
||||
"globalThis.__require.prototype: object",
|
||||
"globalThis.atob: function(1)",
|
||||
"globalThis.atob.prototype: object",
|
||||
"globalThis.btoa: function(1)",
|
||||
"globalThis.btoa.prototype: object",
|
||||
"globalThis.console: object",
|
||||
"globalThis.console.debug: function(0)",
|
||||
"globalThis.console.debug.prototype: object",
|
||||
"globalThis.console.error: function(0)",
|
||||
"globalThis.console.error.prototype: object",
|
||||
"globalThis.console.info: function(0)",
|
||||
"globalThis.console.info.prototype: object",
|
||||
"globalThis.console.log: function(0)",
|
||||
"globalThis.console.log.prototype: object",
|
||||
"globalThis.console.warn: function(0)",
|
||||
"globalThis.console.warn.prototype: object",
|
||||
"globalThis.crypto: object",
|
||||
"globalThis.crypto.getRandomValues: function(1)",
|
||||
"globalThis.crypto.getRandomValues.prototype: object",
|
||||
"globalThis.crypto.randomUUID: function(0)",
|
||||
"globalThis.crypto.randomUUID.prototype: object",
|
||||
"globalThis.crypto.subtle: object",
|
||||
"globalThis.crypto.subtle.digest: function(2)",
|
||||
"globalThis.crypto.subtle.digest.prototype: object",
|
||||
"globalThis.decodeURI: function(1)[native]",
|
||||
"globalThis.decodeURIComponent: function(1)[native]",
|
||||
"globalThis.encodeURI: function(1)[native]",
|
||||
"globalThis.encodeURIComponent: function(1)[native]",
|
||||
"globalThis.escape: function(1)[native]",
|
||||
"globalThis.eval: function(1)[native]",
|
||||
"globalThis.globalThis: object",
|
||||
"globalThis.globalThis: <alias of globalThis>",
|
||||
"globalThis.isFinite: function(1)[native]",
|
||||
"globalThis.isNaN: function(1)[native]",
|
||||
"globalThis.parseFloat: function(1)[native]",
|
||||
"globalThis.parseFloat: <alias of globalThis.Number.parseFloat>",
|
||||
"globalThis.parseInt: function(2)[native]",
|
||||
"globalThis.parseInt: <alias of globalThis.Number.parseInt>",
|
||||
"globalThis.process: object",
|
||||
"globalThis.process.arch: string",
|
||||
"globalThis.process.argv: object",
|
||||
"globalThis.process.env: object",
|
||||
"globalThis.process.nextTick: function(1)",
|
||||
"globalThis.process.nextTick.prototype: object",
|
||||
"globalThis.process.platform: string",
|
||||
"globalThis.process.version: string",
|
||||
"globalThis.process.versions: object",
|
||||
"globalThis.undefined: undefined",
|
||||
"globalThis.unescape: function(1)[native]",
|
||||
"context.app: object",
|
||||
"context.app.alert: function(2)",
|
||||
"context.app.alert.prototype: object",
|
||||
"context.app.clipboard: object",
|
||||
"context.app.clipboard.clear: function(0)",
|
||||
"context.app.clipboard.clear.prototype: object",
|
||||
"context.app.clipboard.readText: function(0)",
|
||||
"context.app.clipboard.readText.prototype: object",
|
||||
"context.app.clipboard.writeText: function(1)",
|
||||
"context.app.clipboard.writeText.prototype: object",
|
||||
"context.app.dialog: function(1)",
|
||||
"context.app.dialog.prototype: object",
|
||||
"context.app.getInfo: function(0)",
|
||||
"context.app.getInfo.prototype: object",
|
||||
"context.app.getPath: function(1)",
|
||||
"context.app.getPath.prototype: object",
|
||||
"context.app.prompt: function(2)",
|
||||
"context.app.prompt.prototype: object",
|
||||
"context.app.showSaveDialog: function(1)",
|
||||
"context.app.showSaveDialog.prototype: object",
|
||||
"context.context: object",
|
||||
"context.meta: object",
|
||||
"context.network: object",
|
||||
"context.network.sendRequest: function(2)",
|
||||
"context.network.sendRequest.prototype: object",
|
||||
"context.network.sendRequestWithoutSideEffects: function(1)",
|
||||
"context.network.sendRequestWithoutSideEffects.prototype: object",
|
||||
"context.renderPurpose: string",
|
||||
"context.store: object",
|
||||
"context.store.all: function(0)",
|
||||
"context.store.all.prototype: object",
|
||||
"context.store.clear: function(0)",
|
||||
"context.store.clear.prototype: object",
|
||||
"context.store.getItem: function(1)",
|
||||
"context.store.getItem.prototype: object",
|
||||
"context.store.hasItem: function(1)",
|
||||
"context.store.hasItem.prototype: object",
|
||||
"context.store.removeItem: function(1)",
|
||||
"context.store.removeItem.prototype: object",
|
||||
"context.store.setItem: function(2)",
|
||||
"context.store.setItem.prototype: object",
|
||||
"context.util: object",
|
||||
"context.util.decode: function(2)",
|
||||
"context.util.decode.prototype: object",
|
||||
"context.util.encode: function(2)",
|
||||
"context.util.encode.prototype: object",
|
||||
"context.util.models: object",
|
||||
"context.util.models.cloudCredential: object",
|
||||
"context.util.models.cloudCredential.getById: function(1)",
|
||||
"context.util.models.cloudCredential.update: function(2)",
|
||||
"context.util.models.cookieJar: object",
|
||||
"context.util.models.cookieJar.getCookiesForUrl: function(2)",
|
||||
"context.util.models.cookieJar.getOrCreateForParentId: function(1)",
|
||||
"context.util.models.oAuth2Token: object",
|
||||
"context.util.models.oAuth2Token.getByRequestId: function(1)",
|
||||
"context.util.models.request: object",
|
||||
"context.util.models.request.getAncestors: function(1)",
|
||||
"context.util.models.request.getById: function(1)",
|
||||
"context.util.models.response: object",
|
||||
"context.util.models.response.getBodyBuffer: function(2)",
|
||||
"context.util.models.response.getLatestForRequestId: function(2)",
|
||||
"context.util.models.settings: object",
|
||||
"context.util.models.settings.get: function(0)",
|
||||
"context.util.models.workspace: object",
|
||||
"context.util.models.workspace.getById: function(1)",
|
||||
"context.util.nodeOS: function(0)",
|
||||
"context.util.nodeOS.prototype: object",
|
||||
"context.util.openInBrowser: function(1)",
|
||||
"context.util.openInBrowser.prototype: object",
|
||||
"context.util.readFile: function(2)",
|
||||
"context.util.readFile.prototype: object",
|
||||
"context.util.render: function(1)",
|
||||
"context.util.render.prototype: object",
|
||||
"require("path"): object",
|
||||
"require("path").basename: function(1)",
|
||||
"require("path").basename.prototype: object",
|
||||
"require("path").extname: function(1)",
|
||||
"require("path").extname.prototype: object",
|
||||
"require("path").join: function(0)",
|
||||
"require("path").join.prototype: object",
|
||||
"require("path").sep: string",
|
||||
"require("crypto"): object",
|
||||
"require("crypto").createHash: function(1)",
|
||||
"require("crypto").createHash.prototype: object",
|
||||
"require("crypto").createHmac: function(2)",
|
||||
"require("crypto").createHmac.prototype: object",
|
||||
"require("crypto").randomBytes: function(1)",
|
||||
"require("crypto").randomBytes.prototype: object",
|
||||
"require("crypto").randomUUID: function(0)",
|
||||
"require("crypto").randomUUID.prototype: object",
|
||||
"require("events"): object",
|
||||
"require("events").EventEmitter: function(0)",
|
||||
"require("events").EventEmitter.prototype: object",
|
||||
"require("uuid"): object",
|
||||
"require("uuid").MAX: string",
|
||||
"require("uuid").NIL: string",
|
||||
"require("uuid").__esModule: boolean",
|
||||
"require("uuid").parse: function(1)",
|
||||
"require("uuid").parse.prototype: object",
|
||||
"require("uuid").stringify: function(1)",
|
||||
"require("uuid").stringify.prototype: object",
|
||||
"require("uuid").v1: function(3)",
|
||||
"require("uuid").v1.prototype: object",
|
||||
"require("uuid").v1ToV6: function(1)",
|
||||
"require("uuid").v1ToV6.prototype: object",
|
||||
"require("uuid").v3: function(4)",
|
||||
"require("uuid").v3.DNS: string",
|
||||
"require("uuid").v3.URL: string",
|
||||
"require("uuid").v3.prototype: object",
|
||||
"require("uuid").v4: function(3)",
|
||||
"require("uuid").v4.prototype: object",
|
||||
"require("uuid").v5: function(4)",
|
||||
"require("uuid").v5.DNS: string",
|
||||
"require("uuid").v5.URL: string",
|
||||
"require("uuid").v5.prototype: object",
|
||||
"require("uuid").v6: function(3)",
|
||||
"require("uuid").v6.prototype: object",
|
||||
"require("uuid").v6ToV1: function(1)",
|
||||
"require("uuid").v6ToV1.prototype: object",
|
||||
"require("uuid").v7: function(3)",
|
||||
"require("uuid").v7.prototype: object",
|
||||
"require("uuid").validate: function(1)",
|
||||
"require("uuid").validate.prototype: object",
|
||||
"require("uuid").version: function(1)",
|
||||
"require("uuid").version.prototype: object",
|
||||
"require("ajv"): function(0)",
|
||||
"require("ajv").Ajv: function(0)",
|
||||
"require("ajv").Ajv: <alias of require("ajv")>",
|
||||
"require("ajv").CodeGen: function(1)",
|
||||
"require("ajv").CodeGen.prototype: object",
|
||||
"require("ajv").KeywordCxt: function(3)",
|
||||
"require("ajv").KeywordCxt.prototype: object",
|
||||
"require("ajv").MissingRefError: function(4)",
|
||||
"require("ajv").MissingRefError.prototype: object",
|
||||
"require("ajv").Name: function(1)",
|
||||
"require("ajv").Name.prototype: object",
|
||||
"require("ajv").ValidationError: function(1)",
|
||||
"require("ajv").ValidationError.prototype: object",
|
||||
"require("ajv")._: function(1)",
|
||||
"require("ajv")._.prototype: object",
|
||||
"require("ajv").__esModule: boolean",
|
||||
"require("ajv").default: function(0)",
|
||||
"require("ajv").default: <alias of require("ajv")>",
|
||||
"require("ajv").nil: object",
|
||||
"require("ajv").nil._items: object",
|
||||
"require("ajv").nil._items.0: string",
|
||||
"require("ajv").prototype: object",
|
||||
"require("ajv").prototype._addDefaultMetaSchema: function(0)",
|
||||
"require("ajv").prototype._addVocabularies: function(0)",
|
||||
"require("ajv").prototype.constructor: function(0)",
|
||||
"require("ajv").prototype.constructor: <alias of require("ajv")>",
|
||||
"require("ajv").prototype.defaultMeta: function(0)",
|
||||
"require("ajv").str: function(1)",
|
||||
"require("ajv").str.prototype: object",
|
||||
"require("ajv").stringify: function(1)",
|
||||
"require("ajv").stringify.prototype: object",
|
||||
]
|
||||
`;
|
||||
@@ -177,6 +177,68 @@ export const IN_SANDBOX_BOOTSTRAP = [
|
||||
' return __moduleCache[canonical];',
|
||||
' };',
|
||||
|
||||
// --- relative-module loader over the plugin\'s own files (M4, multi-file plugins) ---',
|
||||
// The host reads the plugin\'s source files (only within its own directory, never node_modules)
|
||||
// into __moduleFiles: { posixRelativeKey -> source }. A relative require ("./util", "../x") is
|
||||
// resolved from that map and compiled here via `new Function` (source is envelope DATA, not
|
||||
// host-eval\'d code). A bare specifier ("uuid") still routes through the grant-gated __require, so
|
||||
// the registry is the only source of third-party modules — the plugin\'s node_modules is never
|
||||
// consulted (the "poison" guarantee).
|
||||
// Null-prototype copy so `in` below can\'t match an inherited Object.prototype member.
|
||||
' var __moduleFiles = (function (src) {',
|
||||
' var out = Object.create(null);',
|
||||
' for (var k in src) { if (Object.prototype.hasOwnProperty.call(src, k)) { out[k] = src[k]; } }',
|
||||
' return out;',
|
||||
' })((__env && __env.moduleFiles) || {});',
|
||||
' var __pluginModuleCache = Object.create(null);',
|
||||
' function __dirOfKey(key) { var i = key.lastIndexOf("/"); return i === -1 ? "" : key.slice(0, i); }',
|
||||
// __normalizeKey returns null when the path traverses above the plugin root (e.g.
|
||||
// require("../../util") from a shallow dir). Node would resolve that outside the plugin; clamping
|
||||
// it back into the root could make an out-of-plugin path masquerade as an in-plugin module, so
|
||||
// treat above-root traversal as unresolvable.
|
||||
' function __normalizeKey(p) {',
|
||||
' var parts = p.split("/"); var out = [];',
|
||||
' for (var i = 0; i < parts.length; i++) {',
|
||||
' var seg = parts[i];',
|
||||
' if (seg === "" || seg === ".") { continue; }',
|
||||
' if (seg === "..") { if (out.length) { out.pop(); continue; } return null; }',
|
||||
' out.push(seg);',
|
||||
' }',
|
||||
' return out.join("/");',
|
||||
' }',
|
||||
' function __resolveRelative(fromDir, spec) {',
|
||||
' var base = __normalizeKey(fromDir ? fromDir + "/" + spec : spec);',
|
||||
' if (base === null) { return null; }',
|
||||
' var candidates = [base, base + ".js", base + ".json", (base ? base + "/" : "") + "index.js"];',
|
||||
' for (var i = 0; i < candidates.length; i++) { if (candidates[i] && (candidates[i] in __moduleFiles)) { return candidates[i]; } }',
|
||||
' return null;',
|
||||
' }',
|
||||
' function __makePluginRequire(fromDir) {',
|
||||
' return function (spec) {',
|
||||
' var s = "" + spec;',
|
||||
' if (s.charAt(0) === ".") {',
|
||||
' var key = __resolveRelative(fromDir, s);',
|
||||
' if (key === null) { var e = new Error("Cannot find module \'" + s + "\'"); e.code = "MODULE_NOT_FOUND"; throw e; }',
|
||||
' return __loadPluginModule(key);',
|
||||
' }',
|
||||
' return globalThis.__require(s);',
|
||||
' };',
|
||||
' }',
|
||||
' function __loadPluginModule(key) {',
|
||||
' if (key in __pluginModuleCache) { return __pluginModuleCache[key].exports; }',
|
||||
' var src = __moduleFiles[key];',
|
||||
' var module = { exports: {} };',
|
||||
' __pluginModuleCache[key] = module;',
|
||||
' if (key.slice(-5) === ".json") { module.exports = JSON.parse(src); return module.exports; }',
|
||||
' var fn = new Function("module", "exports", "require", "__dirname", "__filename", src);',
|
||||
' var dir = __dirOfKey(key);',
|
||||
' fn(module, module.exports, __makePluginRequire(dir), dir, key);',
|
||||
' return module.exports;',
|
||||
' }',
|
||||
' globalThis.__loadPluginEntry = function () {',
|
||||
' return __loadPluginModule(__env.entryModuleKey);',
|
||||
' };',
|
||||
|
||||
// --- rebuild the plugin context over the bulk-copied envelope ---
|
||||
// Every branch is built here, then the ones whose capability is not granted are removed below, so
|
||||
// `context.network` (etc.) is `undefined` — not a present-but-rejecting stub — when ungated. This
|
||||
@@ -262,11 +324,11 @@ export const IN_SANDBOX_BOOTSTRAP = [
|
||||
' return ctx;',
|
||||
' };',
|
||||
|
||||
// --- find the requested tag in the evaluated plugin module and run it ---
|
||||
// --- load the plugin (its entry module, resolving any relative requires) and run the tag ---
|
||||
' globalThis.__invoke = function () {',
|
||||
' var env = __env;',
|
||||
' var ctx = globalThis.__buildContext(env);',
|
||||
' var mod = globalThis.__pluginModule;',
|
||||
' var mod = globalThis.__loadPluginEntry();',
|
||||
' var tags = (mod && mod.templateTags) || [];',
|
||||
' var tag = null;',
|
||||
' for (var i = 0; i < tags.length; i++) { if (tags[i].name === __tag) { tag = tags[i]; break; } }',
|
||||
@@ -280,26 +342,9 @@ export const IN_SANDBOX_BOOTSTRAP = [
|
||||
// context/invocation entry points. __registerModule is intentionally left mutable — the module
|
||||
// registry source deletes it once the registry is populated.
|
||||
' var __lock = function (n) { Object.defineProperty(globalThis, n, { value: globalThis[n], writable: false, configurable: false }); };',
|
||||
' __lock("__require"); __lock("__buildContext"); __lock("__invoke");',
|
||||
' __lock("__require"); __lock("__buildContext"); __lock("__invoke"); __lock("__loadPluginEntry");',
|
||||
'})();',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* Wrap raw plugin source as a CommonJS module, evaluate it, and stash its exports on
|
||||
* `globalThis.__pluginModule` for `__invoke()`. The source is concatenated at runtime (not baked
|
||||
* into a template literal), so plugin code can contain any characters safely.
|
||||
*/
|
||||
export const wrapPluginSource = (source: string): string =>
|
||||
[
|
||||
'globalThis.__pluginModule = (function () {',
|
||||
' var module = { exports: {} };',
|
||||
' var exports = module.exports;',
|
||||
' (function (module, exports, require, __dirname, __filename) {',
|
||||
source,
|
||||
' })(module, exports, globalThis.__require, "", "");',
|
||||
' return module.exports;',
|
||||
'})();',
|
||||
].join('\n');
|
||||
|
||||
/** The runner: kicks off the async invocation and parks the VM promise on `globalThis.__task`. */
|
||||
export const RUNNER = 'globalThis.__task = globalThis.__invoke();';
|
||||
|
||||
@@ -33,6 +33,16 @@ export interface ContextEnvelope {
|
||||
* from the rebuilt `context`.
|
||||
*/
|
||||
grantedCapabilities: string[];
|
||||
/**
|
||||
* The plugin's own source files (M4), keyed by POSIX path relative to the plugin directory —
|
||||
* read host-side within the plugin dir only (never `node_modules`). The in-sandbox loader resolves
|
||||
* relative `require()`s against this map; bare specifiers still go through the grant-gated registry.
|
||||
* `runTagInSandbox` fills these in from a single-file `pluginSource` when not provided, so
|
||||
* single-module callers/tests need not construct a map.
|
||||
*/
|
||||
moduleFiles?: Record<string, string>;
|
||||
/** Key into `moduleFiles` for the plugin's entry module (from package.json `main`). */
|
||||
entryModuleKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,35 +53,18 @@ const PATH_FACTORY = [
|
||||
].join('\n');
|
||||
|
||||
// crypto is backed by synchronous host functions (real host crypto), so digest()/randomBytes()
|
||||
// return values inline — matching node:crypto's synchronous contract.
|
||||
// return values inline — matching node:crypto's synchronous contract. The actual createHash/
|
||||
// createHmac/randomBytes/randomUUID implementation lives in sandbox-globals.ts's
|
||||
// `globalThis.__nodeCryptoExports`, built eagerly in the same scope that captures the raw
|
||||
// `__crypto*` host natives — this factory only runs lazily (on the plugin's first `require('crypto')`
|
||||
// call), by which point those raw natives are already gone from `globalThis`, so it can't read them
|
||||
// directly. It reads `__cryptoExportsCapture` instead — a variable closed over from
|
||||
// buildModuleRegistrySource's wrapping IIFE (see below), not `globalThis.__nodeCryptoExports`
|
||||
// directly, which is captured-then-deleted there before any plugin code runs.
|
||||
const CRYPTO_FACTORY = [
|
||||
'function () {',
|
||||
' if (typeof globalThis.__cryptoHash !== "function") { throw new Error("\'crypto\' is not available in this sandbox"); }',
|
||||
' var mkDigester = function (compute) {',
|
||||
' var parts = [];',
|
||||
' var obj = {',
|
||||
' update: function (data) { parts.push(typeof data === "string" ? data : String(data)); return obj; },',
|
||||
' digest: function (enc) { return compute(parts.join(""), enc || "hex"); }',
|
||||
' };',
|
||||
' return obj;',
|
||||
' };',
|
||||
' return {',
|
||||
' createHash: function (algo) { return mkDigester(function (data, enc) { return globalThis.__cryptoHash(algo, data, "utf8", enc); }); },',
|
||||
' createHmac: function (algo, key) { var k = typeof key === "string" ? key : String(key); return mkDigester(function (data, enc) { return globalThis.__cryptoHmac(algo, k, data, enc); }); },',
|
||||
' randomBytes: function (size) {',
|
||||
' var b64 = globalThis.__cryptoRandomBytes(size);',
|
||||
' var binary = atob(b64);',
|
||||
' return {',
|
||||
' length: binary.length,',
|
||||
' toString: function (enc) {',
|
||||
' if (enc === "base64") { return b64; }',
|
||||
' if (enc === "latin1" || enc === "binary") { return binary; }',
|
||||
' var hex = ""; for (var i = 0; i < binary.length; i++) { hex += ("0" + binary.charCodeAt(i).toString(16)).slice(-2); } return hex;',
|
||||
' }',
|
||||
' };',
|
||||
' },',
|
||||
' randomUUID: function () { return globalThis.__cryptoRandomUUID(); }',
|
||||
' };',
|
||||
' if (typeof __cryptoExportsCapture === "undefined") { throw new Error("\'crypto\' is not available in this sandbox"); }',
|
||||
' return __cryptoExportsCapture;',
|
||||
'}',
|
||||
].join('\n');
|
||||
|
||||
@@ -174,8 +157,16 @@ const registerCall = (m: SandboxModuleDefinition): string => {
|
||||
export const buildModuleRegistrySource = (grantedModules: string[] = []): string => {
|
||||
const granted = new Set(grantedModules);
|
||||
return [
|
||||
'(function(){',
|
||||
// Capture then drop sandbox-globals.ts's hand-off object — this IIFE runs synchronously, once,
|
||||
// before any plugin code (RUNNER evaluates last), so there's no window where a plugin could ever
|
||||
// observe globalThis.__nodeCryptoExports as a bare global. CRYPTO_FACTORY closes over this local
|
||||
// instead, so it stays available whenever require('crypto') is first (lazily) called.
|
||||
' var __cryptoExportsCapture = globalThis.__nodeCryptoExports;',
|
||||
' delete globalThis.__nodeCryptoExports;',
|
||||
...SANDBOX_MODULES.filter(m => !m.heavy || granted.has(m.name)).map(registerCall),
|
||||
// Lock the registry once populated — plugin code must not be able to register or replace factories.
|
||||
'delete globalThis.__registerModule;',
|
||||
' delete globalThis.__registerModule;',
|
||||
'})();',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import nodeCrypto from 'node:crypto';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type HostBridge } from './host-bridge';
|
||||
import { type ContextEnvelope } from './marshal';
|
||||
import { type HostCrypto, runTagInSandbox } from './plugin-tag-sandbox';
|
||||
|
||||
// Multi-file plugin support (M4): the plugin's own files travel in the envelope's moduleFiles map,
|
||||
// and the in-sandbox loader resolves relative require()s against it while bare specifiers still hit
|
||||
// the grant-gated registry. These tests drive that loader directly with hand-built maps (the host
|
||||
// walker that produces the map is tested in main/__tests__/templating-worker-database.test.ts).
|
||||
const nodeHostCrypto: HostCrypto = {
|
||||
hash: (a, d, i, o) =>
|
||||
nodeCrypto
|
||||
.createHash(a)
|
||||
.update(d, i as nodeCrypto.Encoding)
|
||||
.digest(o as nodeCrypto.BinaryToTextEncoding),
|
||||
hmac: (a, k, d, o) =>
|
||||
nodeCrypto
|
||||
.createHmac(a, k)
|
||||
.update(d, 'utf8')
|
||||
.digest(o as nodeCrypto.BinaryToTextEncoding),
|
||||
randomBytes: n => nodeCrypto.randomBytes(n).toString('base64'),
|
||||
randomUUID: () => nodeCrypto.randomUUID(),
|
||||
};
|
||||
|
||||
const noBridge: HostBridge = async path => {
|
||||
throw new Error(`unexpected bridge call: ${path}`);
|
||||
};
|
||||
|
||||
const envelope = (
|
||||
moduleFiles: Record<string, string>,
|
||||
entryModuleKey = 'index.js',
|
||||
grantedModules: string[] = ['path', 'crypto'],
|
||||
): ContextEnvelope => ({
|
||||
args: [],
|
||||
context: {},
|
||||
meta: {},
|
||||
renderPurpose: 'preview',
|
||||
appInfo: { version: '0.0.0', platform: 'linux', arch: 'x64' },
|
||||
pluginName: 'test-plugin',
|
||||
renderDepth: 0,
|
||||
grantedModules,
|
||||
grantedCapabilities: [],
|
||||
moduleFiles,
|
||||
entryModuleKey,
|
||||
});
|
||||
|
||||
const runMap = (moduleFiles: Record<string, string>, grantedModules?: string[]) =>
|
||||
runTagInSandbox({
|
||||
tagName: 't',
|
||||
envelope: envelope(moduleFiles, 'index.js', grantedModules),
|
||||
bridge: noBridge,
|
||||
hostCrypto: nodeHostCrypto,
|
||||
});
|
||||
|
||||
describe('multi-file plugins (M4)', () => {
|
||||
it('resolves relative requires across files and nested directories', async () => {
|
||||
const actual = await runMap({
|
||||
'index.js': `
|
||||
var util = require('./util');
|
||||
var helper = require('./nested/helper');
|
||||
module.exports.templateTags = [{ name: 't', run: function () { return util.a() + '+' + helper.b(); } }];`,
|
||||
'util.js': "module.exports.a = function () { return 'util-a'; };",
|
||||
'nested/helper.js':
|
||||
"var deep = require('../util'); module.exports.b = function () { return 'helper-' + deep.a(); };",
|
||||
});
|
||||
expect(actual).toBe('util-a+helper-util-a');
|
||||
});
|
||||
|
||||
it('resolves a directory import to its index.js', async () => {
|
||||
const actual = await runMap({
|
||||
'index.js':
|
||||
"var lib = require('./lib'); module.exports.templateTags = [{ name: 't', run: function () { return lib.v; } }];",
|
||||
'lib/index.js': "module.exports.v = 'from-lib-index';",
|
||||
});
|
||||
expect(actual).toBe('from-lib-index');
|
||||
});
|
||||
|
||||
it('bundles relative files in but still routes a bare require to the registry (hybrid)', async () => {
|
||||
const actual = await runMap(
|
||||
{
|
||||
'index.js': `
|
||||
var makeId = require('./make-id');
|
||||
module.exports.templateTags = [{ name: 't', run: function () { return makeId(); } }];`,
|
||||
'make-id.js':
|
||||
"var uuid = require('uuid'); module.exports = function () { return uuid.validate(uuid.v4()) ? 'id-ok' : 'id-bad'; };",
|
||||
},
|
||||
['path', 'crypto', 'uuid'],
|
||||
);
|
||||
expect(actual).toBe('id-ok');
|
||||
});
|
||||
|
||||
it('shares module state — a relative module is evaluated once (CommonJS cache)', async () => {
|
||||
const actual = await runMap({
|
||||
'index.js': `
|
||||
var a = require('./counter'); var b = require('./counter');
|
||||
a.inc(); b.inc();
|
||||
module.exports.templateTags = [{ name: 't', run: function () { return String(require('./counter').value); } }];`,
|
||||
'counter.js': 'module.exports.value = 0; module.exports.inc = function () { module.exports.value++; };',
|
||||
});
|
||||
expect(actual).toBe('2');
|
||||
});
|
||||
|
||||
it('throws Cannot find module for an unresolvable relative import', async () => {
|
||||
await expect(
|
||||
runMap({
|
||||
'index.js':
|
||||
"require('./missing'); module.exports.templateTags = [{ name: 't', run: function () { return 'x'; } }];",
|
||||
}),
|
||||
).rejects.toThrow(/Cannot find module '\.\/missing'/);
|
||||
});
|
||||
|
||||
it('does not clamp an above-root traversal back into the plugin (treats it as unresolvable)', async () => {
|
||||
// nested/deep/leaf reaches three levels up — above the plugin root. A util.js exists at the root,
|
||||
// but the escaping specifier must NOT resolve to it (Node would look outside the plugin). Clamping
|
||||
// `..` at the root would let an out-of-plugin path masquerade as an in-plugin module.
|
||||
await expect(
|
||||
runMap({
|
||||
'index.js':
|
||||
"require('./nested/deep/leaf'); module.exports.templateTags = [{ name: 't', run: function () { return 'x'; } }];",
|
||||
'nested/deep/leaf.js': "module.exports = require('../../../util');",
|
||||
'util.js': "module.exports = 'root-util';",
|
||||
}),
|
||||
).rejects.toThrow(/Cannot find module '\.\.\/\.\.\/\.\.\/util'/);
|
||||
});
|
||||
|
||||
it('propagates a syntax error in a required relative file (broken plugin)', async () => {
|
||||
await expect(
|
||||
runMap({
|
||||
'index.js':
|
||||
"require('./broken'); module.exports.templateTags = [{ name: 't', run: function () { return 'x'; } }];",
|
||||
'broken.js': 'module.exports = function ( { syntax error here',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('a poisoned map key cannot shadow a bare registry module (bare never hits relative map)', async () => {
|
||||
// Even if the map contained a "uuid.js", a BARE require('uuid') resolves via the registry, not
|
||||
// the map — only relative specifiers consult moduleFiles. (The host walker also excludes
|
||||
// node_modules entirely, so this key never exists in practice.)
|
||||
const actual = await runMap(
|
||||
{
|
||||
'index.js':
|
||||
"var uuid = require('uuid'); module.exports.templateTags = [{ name: 't', run: function () { return uuid.validate(uuid.v4()) ? 'real-uuid' : 'bad'; } }];",
|
||||
'uuid.js': "module.exports = { v4: function () { return 'evil'; }, validate: function () { return true; } };",
|
||||
},
|
||||
['path', 'crypto', 'uuid'],
|
||||
);
|
||||
expect(actual).toBe('real-uuid');
|
||||
});
|
||||
|
||||
// Guards against `in` matching an inherited Object.prototype member instead of failing closed.
|
||||
describe('does not resolve a nonexistent relative specifier that shadows an Object.prototype member', () => {
|
||||
it.each([
|
||||
'constructor',
|
||||
'toString',
|
||||
'valueOf',
|
||||
'hasOwnProperty',
|
||||
'isPrototypeOf',
|
||||
'propertyIsEnumerable',
|
||||
'toLocaleString',
|
||||
])('require("./%s") with no such file throws Cannot find module', async name => {
|
||||
await expect(
|
||||
runMap({
|
||||
'index.js': `require('./${name}'); module.exports.templateTags = [{ name: 't', run: function () { return 'x'; } }];`,
|
||||
}),
|
||||
// Substring match (not a RegExp) — the expected message is a fixed string, and building a
|
||||
// RegExp from the interpolated name trips Semgrep's non-literal-regexp / ReDoS rule for no gain.
|
||||
).rejects.toThrow(`Cannot find module './${name}'`);
|
||||
});
|
||||
});
|
||||
|
||||
it('still resolves a real file whose name happens to match an Object.prototype member', async () => {
|
||||
const actual = await runMap({
|
||||
'index.js':
|
||||
"var c = require('./constructor'); module.exports.templateTags = [{ name: 't', run: function () { return c.v; } }];",
|
||||
'constructor.js': "module.exports.v = 'real-constructor-file';",
|
||||
});
|
||||
expect(actual).toBe('real-constructor-file');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { QuickJSContext, QuickJSHandle } from 'quickjs-emscripten';
|
||||
|
||||
import type { HostBridge } from './host-bridge';
|
||||
import { IN_SANDBOX_BOOTSTRAP, RUNNER, wrapPluginSource } from './in-sandbox-bootstrap';
|
||||
import { IN_SANDBOX_BOOTSTRAP, RUNNER } from './in-sandbox-bootstrap';
|
||||
import { type ContextEnvelope, encodeBridgeFailure, encodeBridgeSuccess } from './marshal';
|
||||
import { buildModuleRegistrySource } from './module-registry';
|
||||
import { SANDBOX_GLOBALS_SOURCE } from './sandbox-globals';
|
||||
@@ -20,8 +20,12 @@ export interface HostCrypto {
|
||||
}
|
||||
|
||||
export interface RunTagInSandboxOptions {
|
||||
/** Raw CommonJS source of the plugin module that exports `templateTags`. */
|
||||
pluginSource: string;
|
||||
/**
|
||||
* Raw CommonJS source of the plugin's entry module. Convenience for single-file plugins/tests;
|
||||
* synthesized into `envelope.moduleFiles` as `index.js`. Ignored when the envelope already carries
|
||||
* a module map (multi-file plugins, M4).
|
||||
*/
|
||||
pluginSource?: string;
|
||||
/** Name of the tag within the module to execute. */
|
||||
tagName: string;
|
||||
/** Bulk-copied state passed to the rebuilt in-sandbox context. */
|
||||
@@ -48,6 +52,16 @@ export const runTagInSandbox = async (opts: RunTagInSandboxOptions): Promise<str
|
||||
const ctx = QuickJS.newContext();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
// Single-file convenience: synthesize a one-entry module map from pluginSource when the caller
|
||||
// didn't pass a multi-file map. The in-sandbox loader always resolves the entry from the map.
|
||||
const hasMap = !!envelope.moduleFiles && Object.keys(envelope.moduleFiles).length > 0;
|
||||
if (!hasMap && pluginSource === undefined) {
|
||||
throw new Error('runTagInSandbox requires either pluginSource or envelope.moduleFiles');
|
||||
}
|
||||
const fullEnvelope: ContextEnvelope = hasMap
|
||||
? { ...envelope, entryModuleKey: envelope.entryModuleKey ?? 'index.js' }
|
||||
: { ...envelope, moduleFiles: { 'index.js': pluginSource as string }, entryModuleKey: 'index.js' };
|
||||
|
||||
try {
|
||||
// Polled during synchronous execution so a tight sync loop in plugin code can't bypass the timeout.
|
||||
ctx.runtime.setInterruptHandler(() => Date.now() > deadline);
|
||||
@@ -60,7 +74,7 @@ export const runTagInSandbox = async (opts: RunTagInSandboxOptions): Promise<str
|
||||
// The envelope/tag globals are injected BEFORE the bootstrap, which captures them into closure
|
||||
// state and deletes the globals — so plugin top-level code can't rewrite grantedModules (or any
|
||||
// other envelope field) before __invoke() runs.
|
||||
setGlobalString(ctx, '__envelopeJSON', JSON.stringify(envelope));
|
||||
setGlobalString(ctx, '__envelopeJSON', JSON.stringify(fullEnvelope));
|
||||
setGlobalString(ctx, '__tagName', tagName);
|
||||
evalOrThrow(ctx, IN_SANDBOX_BOOTSTRAP, '<sandbox-bootstrap>');
|
||||
// Ambient globals (Buffer/process/crypto/URL) depend on the bootstrap's encoders and the host
|
||||
@@ -69,8 +83,9 @@ export const runTagInSandbox = async (opts: RunTagInSandboxOptions): Promise<str
|
||||
setGlobalString(ctx, '__sandboxAppInfoJSON', JSON.stringify(envelope.appInfo ?? {}));
|
||||
evalOrThrow(ctx, SANDBOX_GLOBALS_SOURCE, '<sandbox-globals>');
|
||||
// Only register heavy vendored libs the plugin was granted, so unrelated renders don't parse them.
|
||||
evalOrThrow(ctx, buildModuleRegistrySource(envelope.grantedModules), '<sandbox-modules>');
|
||||
evalOrThrow(ctx, wrapPluginSource(pluginSource), '<plugin>');
|
||||
evalOrThrow(ctx, buildModuleRegistrySource(fullEnvelope.grantedModules), '<sandbox-modules>');
|
||||
// Plugin source travels as envelope DATA (moduleFiles) and is compiled by the in-sandbox loader
|
||||
// when __invoke() loads the entry — no host-side eval of plugin code.
|
||||
evalOrThrow(ctx, RUNNER, '<runner>');
|
||||
|
||||
return await drivePromiseToString(ctx, timeoutMs);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, it } from 'vitest';
|
||||
|
||||
import { formatSurfaceEntries, getSandboxEscapeProbe, getSandboxSurface } from './sandbox-surface';
|
||||
|
||||
// Not a regression check — prints both probes' output for eyeballing. Run via `npm run sandbox:surface`.
|
||||
it('prints the sandbox capability surface', async () => {
|
||||
const entries = await getSandboxSurface();
|
||||
const lines = formatSurfaceEntries(entries);
|
||||
console.log(lines.join('\n'));
|
||||
console.log(`\n${lines.length} reachable paths`);
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
}, 20_000);
|
||||
|
||||
it('prints the escape probe results', async () => {
|
||||
const escape = await getSandboxEscapeProbe();
|
||||
console.log(JSON.stringify(escape, null, 2));
|
||||
expect(escape).toBeTruthy();
|
||||
}, 20_000);
|
||||
@@ -26,6 +26,66 @@ export const SANDBOX_GLOBALS_SOURCE = [
|
||||
' try { __appInfo = JSON.parse(globalThis.__sandboxAppInfoJSON || "{}"); } catch (e) { __appInfo = {}; }',
|
||||
' delete globalThis.__sandboxAppInfoJSON;',
|
||||
|
||||
// Capture then drop the crypto natives so plugin code can\'t call them directly, bypassing the
|
||||
// crypto/require("crypto") wrappers below — mirrors the __hostBridge capture-then-delete pattern
|
||||
// in in-sandbox-bootstrap.ts. Each is optional (installHostCrypto is skipped when hostCrypto isn\'t
|
||||
// supplied), so the captured locals may be undefined; every call site below already checks
|
||||
// typeof === "function" before use.
|
||||
' var __cryptoHashFn = globalThis.__cryptoHash;',
|
||||
' var __cryptoHmacFn = globalThis.__cryptoHmac;',
|
||||
' var __cryptoRandomBytesFn = globalThis.__cryptoRandomBytes;',
|
||||
' var __cryptoRandomUUIDFn = globalThis.__cryptoRandomUUID;',
|
||||
' delete globalThis.__cryptoHash;',
|
||||
' delete globalThis.__cryptoHmac;',
|
||||
' delete globalThis.__cryptoRandomBytes;',
|
||||
' delete globalThis.__cryptoRandomUUID;',
|
||||
|
||||
// require("crypto") (module-registry.ts) needs the same natives, but its factory only runs lazily
|
||||
// on a plugin\'s first require("crypto") call — arbitrarily later than this IIFE, i.e. after the
|
||||
// raw natives above are already gone. So its exports are built eagerly here, in the same scope as
|
||||
// the capture, as plain interpreted closures over the captured locals (never touching
|
||||
// globalThis.__crypto* again) — module-registry.ts\'s factory just returns this pre-built object.
|
||||
// Safe to leave reachable: like context.*\'s __bridge wrappers, these are interpreted JS with no
|
||||
// raw native passthrough, not the bare host callbacks the capture above hides.
|
||||
' globalThis.__nodeCryptoExports = (function () {',
|
||||
' var mkDigester = function (compute) {',
|
||||
' var parts = [];',
|
||||
' var obj = {',
|
||||
' update: function (data) { parts.push(typeof data === "string" ? data : String(data)); return obj; },',
|
||||
' digest: function (enc) { return compute(parts.join(""), enc || "hex"); }',
|
||||
' };',
|
||||
' return obj;',
|
||||
' };',
|
||||
' return {',
|
||||
' createHash: function (algo) {',
|
||||
' if (typeof __cryptoHashFn !== "function") { throw new Error("crypto is not available in this sandbox"); }',
|
||||
' return mkDigester(function (data, enc) { return __cryptoHashFn(algo, data, "utf8", enc); });',
|
||||
' },',
|
||||
' createHmac: function (algo, key) {',
|
||||
' if (typeof __cryptoHmacFn !== "function") { throw new Error("crypto is not available in this sandbox"); }',
|
||||
' var k = typeof key === "string" ? key : String(key);',
|
||||
' return mkDigester(function (data, enc) { return __cryptoHmacFn(algo, k, data, enc); });',
|
||||
' },',
|
||||
' randomBytes: function (size) {',
|
||||
' if (typeof __cryptoRandomBytesFn !== "function") { throw new Error("crypto is not available in this sandbox"); }',
|
||||
' var b64 = __cryptoRandomBytesFn(size);',
|
||||
' var binary = atob(b64);',
|
||||
' return {',
|
||||
' length: binary.length,',
|
||||
' toString: function (enc) {',
|
||||
' if (enc === "base64") { return b64; }',
|
||||
' if (enc === "latin1" || enc === "binary") { return binary; }',
|
||||
' var hex = ""; for (var i = 0; i < binary.length; i++) { hex += ("0" + binary.charCodeAt(i).toString(16)).slice(-2); } return hex;',
|
||||
' }',
|
||||
' };',
|
||||
' },',
|
||||
' randomUUID: function () {',
|
||||
' if (typeof __cryptoRandomUUIDFn !== "function") { throw new Error("crypto is not available in this sandbox"); }',
|
||||
' return __cryptoRandomUUIDFn();',
|
||||
' }',
|
||||
' };',
|
||||
' })();',
|
||||
|
||||
// --- Buffer (subset: from/alloc/isBuffer/concat + toString for utf8/base64/hex/latin1) ---
|
||||
// Modeled as a Uint8Array with a patched toString + _isBuffer marker — enough for the common
|
||||
// "Buffer.from(x).toString(enc)" plugin usage without dragging in Node's full Buffer.
|
||||
@@ -102,17 +162,17 @@ export const SANDBOX_GLOBALS_SOURCE = [
|
||||
' globalThis.crypto = {',
|
||||
// host-backed; throw (not weak fallback) when the host binding is absent, like getRandomValues.
|
||||
' randomUUID: function () {',
|
||||
' if (typeof globalThis.__cryptoRandomUUID !== "function") { throw new Error("crypto.randomUUID is not available in this sandbox"); }',
|
||||
' return globalThis.__cryptoRandomUUID();',
|
||||
' if (typeof __cryptoRandomUUIDFn !== "function") { throw new Error("crypto.randomUUID is not available in this sandbox"); }',
|
||||
' return __cryptoRandomUUIDFn();',
|
||||
' },',
|
||||
' getRandomValues: function (typedArray) {',
|
||||
' if (typeof globalThis.__cryptoRandomBytes !== "function") { throw new Error("crypto.getRandomValues is not available in this sandbox"); }',
|
||||
' if (typeof __cryptoRandomBytesFn !== "function") { throw new Error("crypto.getRandomValues is not available in this sandbox"); }',
|
||||
' if (!typedArray || typeof typedArray.byteLength !== "number") { throw new TypeError("getRandomValues expects a TypedArray"); }',
|
||||
// Enforce the WebCrypto 65536-byte quota. This both matches the spec (which throws
|
||||
// QuotaExceededError past this limit) and stays within the host __cryptoRandomBytes clamp, so we
|
||||
// never silently zero-fill a tail — a request that would be short-changed fails loudly instead.
|
||||
' if (typedArray.byteLength > 65536) { throw new Error("Failed to execute \'getRandomValues\': The ArrayBufferView\'s byte length (" + typedArray.byteLength + ") exceeds the number of bytes of entropy available via this API (65536)."); }',
|
||||
' var bin = atob(globalThis.__cryptoRandomBytes(typedArray.byteLength));',
|
||||
' var bin = atob(__cryptoRandomBytesFn(typedArray.byteLength));',
|
||||
' var view = new Uint8Array(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength);',
|
||||
' for (var i = 0; i < view.length; i++) { view[i] = bin.charCodeAt(i) & 255; }',
|
||||
' return typedArray;',
|
||||
@@ -120,12 +180,12 @@ export const SANDBOX_GLOBALS_SOURCE = [
|
||||
' subtle: {',
|
||||
' digest: function (algorithm, data) {',
|
||||
' return Promise.resolve().then(function () {',
|
||||
' if (typeof globalThis.__cryptoHash !== "function") { throw new Error("crypto.subtle is not available in this sandbox"); }',
|
||||
' if (typeof __cryptoHashFn !== "function") { throw new Error("crypto.subtle is not available in this sandbox"); }',
|
||||
' var name = typeof algorithm === "string" ? algorithm : (algorithm && algorithm.name);',
|
||||
' var algo = __algoMap[name]; if (!algo) { throw new Error("Unsupported digest algorithm: " + name); }',
|
||||
' var bytes = data instanceof Uint8Array ? data : new Uint8Array(data);',
|
||||
' var latin1 = ""; for (var i = 0; i < bytes.length; i++) { latin1 += String.fromCharCode(bytes[i] & 255); }',
|
||||
' var hex = globalThis.__cryptoHash(algo, latin1, "latin1", "hex");',
|
||||
' var hex = __cryptoHashFn(algo, latin1, "latin1", "hex");',
|
||||
' var out = new Uint8Array(hex.length / 2);',
|
||||
' for (var j = 0; j < out.length; j++) { out[j] = parseInt(hex.substr(j * 2, 2), 16); }',
|
||||
' return out.buffer;',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ALL_SANDBOX_MODULES } from './module-registry';
|
||||
import {
|
||||
findLeakedGatedReferences,
|
||||
findUnaccountedHostNatives,
|
||||
formatSurfaceEntries,
|
||||
getSandboxEscapeProbe,
|
||||
getSandboxSurface,
|
||||
} from './sandbox-surface';
|
||||
|
||||
describe('sandbox surface', () => {
|
||||
it('matches surface snapshot', async () => {
|
||||
const entries = await getSandboxSurface();
|
||||
expect(formatSurfaceEntries(entries)).toMatchSnapshot();
|
||||
}, 20_000);
|
||||
|
||||
// Always on: fails loudly and names the exact module if a registry entry stops resolving.
|
||||
it('all registered modules resolve', async () => {
|
||||
const entries = await getSandboxSurface();
|
||||
for (const name of ALL_SANDBOX_MODULES) {
|
||||
const root = `require(${JSON.stringify(name)})`;
|
||||
const rootEntries = entries.filter(e => e.root === root);
|
||||
const resolved = rootEntries.some(e => e.type !== 'denied');
|
||||
expect(resolved, `expected "${root}" to resolve in the sandbox surface, but found no matching entry`).toBe(true);
|
||||
expect(rootEntries.some(e => e.type === 'denied')).toBe(false);
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
describe('sandbox', () => {
|
||||
// tag.run.apply(null, args) would leak the host global here in sloppy-mode JS if unguarded.
|
||||
it('this resolver', async () => {
|
||||
const r = await getSandboxEscapeProbe();
|
||||
expect(r.moduleThisIsGlobal).toBe(true);
|
||||
expect(r.runThisIsGlobal).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
// The classic same-process vm-sandbox escape technique.
|
||||
it('function(..) resolver', async () => {
|
||||
const r = await getSandboxEscapeProbe();
|
||||
expect(r.viaFunctionCtor.isSandboxGlobal).toBe(true);
|
||||
expect(r.viaIndirectCtor.isSandboxGlobal).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
// Node-only identifiers that should never exist as bare globals in a QuickJS realm.
|
||||
it('ambient globals resolver', async () => {
|
||||
const r = await getSandboxEscapeProbe();
|
||||
expect(r.ambientGlobalLeaks).toEqual({
|
||||
global: 'undefined',
|
||||
require: 'undefined',
|
||||
module: 'undefined',
|
||||
__dirname: 'undefined',
|
||||
__filename: 'undefined',
|
||||
});
|
||||
}, 20_000);
|
||||
|
||||
// sandbox-globals.ts's process shim must stay a fake, empty shell — never real host data.
|
||||
it('process shim resolver', async () => {
|
||||
const r = await getSandboxEscapeProbe();
|
||||
expect(r.processShim.envKeys).toEqual([]);
|
||||
expect(r.processShim.versionsKeys).toEqual([]);
|
||||
expect(r.processShim.hasBinding).toBe('undefined');
|
||||
expect(r.processShim.hasMainModule).toBe('undefined');
|
||||
}, 20_000);
|
||||
|
||||
// Structural fingerprint, not a fixed list — catches any bare host native automatically.
|
||||
it('unaccounted host natives resolver', async () => {
|
||||
const entries = await getSandboxSurface();
|
||||
expect(findUnaccountedHostNatives(entries)).toEqual([]);
|
||||
}, 20_000);
|
||||
|
||||
// Reference-identity check: a gated wrapper that's also reachable bare on globalThis isn't gating anything.
|
||||
it('no gated reference leaks onto bare globalThis (alias resolver)', async () => {
|
||||
const entries = await getSandboxSurface();
|
||||
expect(findLeakedGatedReferences(entries)).toEqual([]);
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { ALL_CAPABILITIES } from './host-bridge';
|
||||
import type { ContextEnvelope } from './marshal';
|
||||
import { ALL_SANDBOX_MODULES } from './module-registry';
|
||||
import { type HostCrypto, runTagInSandbox } from './plugin-tag-sandbox';
|
||||
|
||||
// Real crypto backing so require('crypto') resolves instead of throwing "not available".
|
||||
const nodeHostCrypto: HostCrypto = {
|
||||
hash: (algo, data, inputEncoding, outputEncoding) =>
|
||||
crypto
|
||||
.createHash(algo)
|
||||
.update(data, inputEncoding as crypto.Encoding)
|
||||
.digest(outputEncoding as crypto.BinaryToTextEncoding),
|
||||
hmac: (algo, key, data, outputEncoding) =>
|
||||
crypto
|
||||
.createHmac(algo, key)
|
||||
.update(data, 'utf8')
|
||||
.digest(outputEncoding as crypto.BinaryToTextEncoding),
|
||||
randomBytes: size => crypto.randomBytes(size).toString('base64'),
|
||||
randomUUID: () => crypto.randomUUID(),
|
||||
};
|
||||
|
||||
/** One reachable key path inside the sandbox, as structured data so downstream checks filter on fields directly instead of regex-parsing a rendered line. */
|
||||
export interface SurfaceEntry {
|
||||
/** Full dotted path, e.g. "globalThis.__cryptoHash" or 'require("crypto").createHash'. */
|
||||
path: string;
|
||||
/** The top-level root this path was reached from: "globalThis", "context", or 'require("name")'. */
|
||||
root: string;
|
||||
/** typeof the value, or a sentinel: "denied" (require() threw) / "getterThrew" (property access threw). */
|
||||
type?: string;
|
||||
/** Declared arity — functions only. */
|
||||
arity?: number;
|
||||
/** Function.prototype.toString() shows the engine's native-code marker — functions only. */
|
||||
native?: boolean;
|
||||
/** Whether the value has an own "prototype" property — functions/objects only. */
|
||||
ownPrototype?: boolean;
|
||||
/** Set instead of type/arity/etc. when this exact object was already reached via a different path. */
|
||||
aliasOf?: string;
|
||||
/** Set alongside type === "denied", the thrown error's message. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic-only, not imported by production code. Two probes, used by `npm run sandbox:surface`
|
||||
* and `sandbox-surface.test.ts`: `getSandboxSurface` enumerates every reachable key path inside the
|
||||
* sandbox; `getSandboxEscapeProbe` checks whether any of it actually points back to the real host.
|
||||
*/
|
||||
const buildSurfaceProbeSource = (tagName: string, moduleNames: string[], maxDepth: number): string => `
|
||||
module.exports.templateTags = [{
|
||||
name: ${JSON.stringify(tagName)},
|
||||
args: [],
|
||||
run: function (context) {
|
||||
var seenObjs = [];
|
||||
var seenPaths = [];
|
||||
var entries = [];
|
||||
function hasOwnProp(obj, name) {
|
||||
try { return Object.prototype.hasOwnProperty.call(obj, name); } catch (e) { return false; }
|
||||
}
|
||||
function isNativeFn(fn) {
|
||||
try { return /\\[native code\\]/.test(Function.prototype.toString.call(fn)); } catch (e) { return false; }
|
||||
}
|
||||
// Shared so every entry gets arity/native/ownPrototype computed the same way, whether it's a
|
||||
// walked child property or a top-level require()'d value.
|
||||
function describeValue(path, root, val) {
|
||||
var t = typeof val;
|
||||
var entry = { path: path, root: root, type: t };
|
||||
if (t === 'function') {
|
||||
entry.arity = val.length;
|
||||
entry.native = isNativeFn(val);
|
||||
entry.ownPrototype = hasOwnProp(val, 'prototype');
|
||||
} else if (t === 'object' && val !== null) {
|
||||
entry.ownPrototype = hasOwnProp(val, 'prototype');
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
// Records where an object was first reached across every root, so a later root revisiting the
|
||||
// same object emits a visible "aliasOf" entry instead of silently vanishing.
|
||||
function walk(obj, prefix, root, depth) {
|
||||
if (depth > ${maxDepth}) { return; }
|
||||
if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) { return; }
|
||||
var seenIdx = seenObjs.indexOf(obj);
|
||||
if (seenIdx !== -1) {
|
||||
entries.push({ path: prefix, root: root, aliasOf: seenPaths[seenIdx] });
|
||||
return;
|
||||
}
|
||||
seenObjs.push(obj); seenPaths.push(prefix);
|
||||
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') { continue; } // noise on every function
|
||||
var path = prefix + '.' + name;
|
||||
var val;
|
||||
try { val = obj[name]; } catch (e) { entries.push({ path: path, root: root, type: 'getterThrew' }); continue; }
|
||||
entries.push(describeValue(path, root, val));
|
||||
if (name === 'prototype' && depth >= 1) { continue; } // engine surface, not ours
|
||||
if ((typeof val === 'object' && val !== null) || typeof val === 'function') { walk(val, path, root, depth + 1); }
|
||||
}
|
||||
}
|
||||
walk(globalThis, 'globalThis', 'globalThis', 0);
|
||||
walk(context, 'context', 'context', 0);
|
||||
var moduleNames = ${JSON.stringify(moduleNames)};
|
||||
for (var m = 0; m < moduleNames.length; m++) {
|
||||
var name = moduleNames[m];
|
||||
var rootName = "require(" + JSON.stringify(name) + ")";
|
||||
try {
|
||||
var requiredVal = require(name);
|
||||
// Always recorded so "did this module resolve" never depends on it having walkable children.
|
||||
entries.push(describeValue(rootName, rootName, requiredVal));
|
||||
walk(requiredVal, rootName, rootName, 0);
|
||||
} catch (e) {
|
||||
entries.push({ path: rootName, root: rootName, type: 'denied', error: e.message });
|
||||
}
|
||||
}
|
||||
return JSON.stringify(entries);
|
||||
},
|
||||
}];
|
||||
`;
|
||||
|
||||
/** Renders one SurfaceEntry back to readable "path: type(...)" text, for `npm run sandbox:surface` and the snapshot test. */
|
||||
export const formatSurfaceEntry = (e: SurfaceEntry): string => {
|
||||
if (e.aliasOf) { return `${e.path}: <alias of ${e.aliasOf}>`; }
|
||||
if (e.type === 'denied') { return `${e.path}: <denied: ${e.error}>`; }
|
||||
if (e.type === 'getterThrew') { return `${e.path}: <getter threw>`; }
|
||||
if (e.type === 'function') { return `${e.path}: function(${e.arity})${e.native ? '[native]' : ''}`; }
|
||||
return `${e.path}: ${e.type}`;
|
||||
};
|
||||
|
||||
export const formatSurfaceEntries = (entries: SurfaceEntry[]): string[] => entries.map(formatSurfaceEntry);
|
||||
|
||||
export interface SandboxSurfaceOptions {
|
||||
/** How many property levels deep to walk from each root. */
|
||||
maxDepth?: number;
|
||||
/** Module names to require and walk the exports of. Defaults to every registry module. */
|
||||
moduleNames?: string[];
|
||||
/** Capabilities to grant when building the `context` object. Defaults to every capability. */
|
||||
grantedCapabilities?: ContextEnvelope['grantedCapabilities'];
|
||||
}
|
||||
|
||||
/** Runs the probe plugin in a real sandbox context and returns the structured entries it finds. */
|
||||
export const getSandboxSurface = async (opts: SandboxSurfaceOptions = {}): Promise<SurfaceEntry[]> => {
|
||||
const { maxDepth = 3, moduleNames = ALL_SANDBOX_MODULES, grantedCapabilities = [...ALL_CAPABILITIES] } = opts;
|
||||
const envelope: ContextEnvelope = {
|
||||
args: [],
|
||||
context: {},
|
||||
meta: {},
|
||||
renderPurpose: 'preview',
|
||||
appInfo: { version: '0.0.0', platform: 'linux', arch: 'x64' },
|
||||
pluginName: 'sandbox-surface-probe',
|
||||
renderDepth: 0,
|
||||
grantedModules: moduleNames,
|
||||
grantedCapabilities,
|
||||
};
|
||||
const result = await runTagInSandbox({
|
||||
tagName: 'surface',
|
||||
pluginSource: buildSurfaceProbeSource('surface', moduleNames, maxDepth),
|
||||
envelope,
|
||||
bridge: async path => {
|
||||
throw new Error(`sandbox surface probe: unexpected bridge call: ${path}`);
|
||||
},
|
||||
hostCrypto: nodeHostCrypto,
|
||||
});
|
||||
return JSON.parse(result) as SurfaceEntry[];
|
||||
};
|
||||
|
||||
// No hand-maintained denylist of "dangerous names" is needed for object references: every
|
||||
// capability here bottoms out in a ctx.newFunction(...) install, so the structural scans below
|
||||
// already cover that case with no name to guess in advance.
|
||||
|
||||
// Every ctx.newFunction(...) the host installs reports arity 0 and no own .prototype regardless of
|
||||
// its real arity; these six are the only genuinely zero-arg-by-spec engine builtins that share that
|
||||
// same fingerprint, so an unexpected new entry here means a real host-installed native, not an engine artifact.
|
||||
const KNOWN_ENGINE_ZERO_ARITY_BUILTINS = new Set([
|
||||
'globalThis.Array.of',
|
||||
'globalThis.Date.now',
|
||||
'globalThis.Function.prototype',
|
||||
'globalThis.Iterator.concat',
|
||||
'globalThis.Math.random',
|
||||
'globalThis.Promise.withResolvers',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Structural fingerprint of a host-installed native reachable from globalThis, context, or any
|
||||
* require()'d module, with no advance knowledge of a bridge function's name required. globalThis.*
|
||||
* uses arity-0 + no-own-prototype (real engine builtins are excluded via KNOWN_ENGINE_ZERO_ARITY_BUILTINS);
|
||||
* context and require(...) exports have no legitimate native surface at all, so the native-code marker alone is enough.
|
||||
*/
|
||||
export const findUnaccountedHostNatives = (entries: SurfaceEntry[]): string[] => {
|
||||
const globalNatives = entries.filter(
|
||||
e =>
|
||||
e.root === 'globalThis' &&
|
||||
e.type === 'function' &&
|
||||
e.arity === 0 &&
|
||||
!e.ownPrototype &&
|
||||
!KNOWN_ENGINE_ZERO_ARITY_BUILTINS.has(e.path),
|
||||
);
|
||||
const contextAndModuleNatives = entries.filter(
|
||||
e => (e.root === 'context' || e.root.startsWith('require(')) && e.type === 'function' && e.native,
|
||||
);
|
||||
return [...globalNatives, ...contextAndModuleNatives].map(e => e.path);
|
||||
};
|
||||
|
||||
/** Flags anything under context or require(...) that's the exact same object as something already reachable bare on globalThis — a "gate" that leaks its reference instead of actually gating. */
|
||||
export const findLeakedGatedReferences = (entries: SurfaceEntry[]): string[] =>
|
||||
entries
|
||||
.filter(e => !!e.aliasOf && e.aliasOf.startsWith('globalThis.') && (e.root === 'context' || e.root.startsWith('require(')))
|
||||
.map(e => `${e.path} aliases ${e.aliasOf}`);
|
||||
|
||||
const ESCAPE_PROBE_TAG_NAME = 'escapeProbe';
|
||||
|
||||
// Checks whether `this`, Function('return this')(), ambient globals, or the process shim ever
|
||||
// point back to the real host realm instead of the sandbox's own.
|
||||
const ESCAPE_PROBE_SOURCE = `
|
||||
globalThis.__probeModuleThisIsGlobal = (this === globalThis);
|
||||
globalThis.__probeModuleThisType = typeof this;
|
||||
|
||||
module.exports.templateTags = [{
|
||||
name: ${JSON.stringify(ESCAPE_PROBE_TAG_NAME)},
|
||||
args: [],
|
||||
run: function () {
|
||||
var runThisIsGlobal = (this === globalThis);
|
||||
var runThisType = typeof this;
|
||||
|
||||
var viaFunctionCtor;
|
||||
try {
|
||||
var g = Function('return this')();
|
||||
viaFunctionCtor = { isSandboxGlobal: g === globalThis, type: typeof g };
|
||||
} catch (e) { viaFunctionCtor = { error: e.message }; }
|
||||
|
||||
var viaIndirectCtor;
|
||||
try {
|
||||
var g2 = (function () {}).constructor('return this')();
|
||||
viaIndirectCtor = { isSandboxGlobal: g2 === globalThis, type: typeof g2 };
|
||||
} catch (e) { viaIndirectCtor = { error: e.message }; }
|
||||
|
||||
var proc = globalThis.process || {};
|
||||
return JSON.stringify({
|
||||
runThisIsGlobal: runThisIsGlobal,
|
||||
runThisType: runThisType,
|
||||
moduleThisIsGlobal: globalThis.__probeModuleThisIsGlobal,
|
||||
moduleThisType: globalThis.__probeModuleThisType,
|
||||
viaFunctionCtor: viaFunctionCtor,
|
||||
viaIndirectCtor: viaIndirectCtor,
|
||||
ambientGlobalLeaks: {
|
||||
global: typeof globalThis.global,
|
||||
require: typeof globalThis.require,
|
||||
module: typeof globalThis.module,
|
||||
__dirname: typeof globalThis.__dirname,
|
||||
__filename: typeof globalThis.__filename,
|
||||
},
|
||||
processShim: {
|
||||
envKeys: Object.keys(proc.env || {}),
|
||||
versionsKeys: Object.keys(proc.versions || {}),
|
||||
hasBinding: typeof proc.binding,
|
||||
hasMainModule: typeof proc.mainModule,
|
||||
},
|
||||
});
|
||||
},
|
||||
}];
|
||||
`;
|
||||
|
||||
export interface EscapeProbeResult {
|
||||
runThisIsGlobal: boolean;
|
||||
runThisType: string;
|
||||
moduleThisIsGlobal: boolean;
|
||||
moduleThisType: string;
|
||||
viaFunctionCtor: { isSandboxGlobal?: boolean; type?: string; error?: string };
|
||||
viaIndirectCtor: { isSandboxGlobal?: boolean; type?: string; error?: string };
|
||||
ambientGlobalLeaks: Record<'global' | 'require' | 'module' | '__dirname' | '__filename', string>;
|
||||
processShim: { envKeys: string[]; versionsKeys: string[]; hasBinding: string; hasMainModule: string };
|
||||
}
|
||||
|
||||
export const getSandboxEscapeProbe = async (): Promise<EscapeProbeResult> => {
|
||||
const envelope: ContextEnvelope = {
|
||||
args: [],
|
||||
context: {},
|
||||
meta: {},
|
||||
renderPurpose: 'preview',
|
||||
appInfo: { version: '0.0.0', platform: 'linux', arch: 'x64' },
|
||||
pluginName: 'sandbox-escape-probe',
|
||||
renderDepth: 0,
|
||||
grantedModules: [],
|
||||
grantedCapabilities: [],
|
||||
};
|
||||
const result = await runTagInSandbox({
|
||||
tagName: ESCAPE_PROBE_TAG_NAME,
|
||||
pluginSource: ESCAPE_PROBE_SOURCE,
|
||||
envelope,
|
||||
bridge: async path => {
|
||||
throw new Error(`sandbox escape probe: unexpected bridge call: ${path}`);
|
||||
},
|
||||
});
|
||||
return JSON.parse(result) as EscapeProbeResult;
|
||||
};
|
||||
@@ -15,8 +15,16 @@ import { UUID_FACTORY_SOURCE } from './vendored/uuid.generated';
|
||||
// in the dedicated <name>.regression.test.ts files alongside this one — kept separate since they're
|
||||
// larger and scoped to one library each.
|
||||
const nodeHostCrypto: HostCrypto = {
|
||||
hash: (a, d, i, o) => nodeCrypto.createHash(a).update(d, i as nodeCrypto.Encoding).digest(o as nodeCrypto.BinaryToTextEncoding),
|
||||
hmac: (a, k, d, o) => nodeCrypto.createHmac(a, k).update(d, 'utf8').digest(o as nodeCrypto.BinaryToTextEncoding),
|
||||
hash: (a, d, i, o) =>
|
||||
nodeCrypto
|
||||
.createHash(a)
|
||||
.update(d, i as nodeCrypto.Encoding)
|
||||
.digest(o as nodeCrypto.BinaryToTextEncoding),
|
||||
hmac: (a, k, d, o) =>
|
||||
nodeCrypto
|
||||
.createHmac(a, k)
|
||||
.update(d, 'utf8')
|
||||
.digest(o as nodeCrypto.BinaryToTextEncoding),
|
||||
randomBytes: n => nodeCrypto.randomBytes(n).toString('base64'),
|
||||
randomUUID: () => nodeCrypto.randomUUID(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user