All checks were successful
CI / Build container images (push) Has been skipped
CI / Lint, typecheck, test (push) Successful in 2m57s
CI / Import/export fidelity gate (push) Successful in 46s
CD / Build and push images (push) Successful in 3m16s
CD / Deploy to Test (push) Successful in 8s
CI / Auth e2e pack (push) Successful in 4m8s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 10s
Implements the security core of the plugin system: code-plugin surfaces run in opaque-origin iframes (sandbox="allow-scripts", never allow-same-origin) with a capability-filtered RPC bridge. - api: serve a per-plugin sandbox frame document at /plugins/:id/:version/frame with a CSP that pins every load to the plugin's own asset path (built from APP_BASE_URL, not the request Host, so a Host-rewriting proxy cannot break it) and forbids network access (connect-src 'none'). Plugin assets get Access-Control-Allow-Origin: * so the null-origin frame can load its own module bundle. - web: sandbox-host creates the frame, wires the SDK host bridge over a source-filtered postMessage transport, drives render under a 5 s deadline (hung/failed plugin -> placeholder, never a frozen page), and tears down on unmount. PluginFrame/PluginPreviewPage surface it; the built-in ui.resize handler clamps plugin-requested heights. - plugin-sdk: host bridge reports gate violations via onViolation and registers a gated handler for every v1 method, so an undeclared capability is rejected with capability_not_permitted (not unknown_method). - tests: SDK gate unit test; web sandbox unit tests (opaque origin, source filtering, timeout); and the e2e security pack with a permanent malicious fixture plugin proving no escape (DOM/cookies/storage/fetch/ undeclared capability all blocked) plus well-behaved and hung cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
141 lines
4.2 KiB
TypeScript
141 lines
4.2 KiB
TypeScript
import { strToU8, zipSync } from 'fflate';
|
|
|
|
/**
|
|
* Fixture plugins for the sandbox security pack (issue #73). These are the
|
|
* permanent regression assets ADR 0008 asks for: a well-behaved plugin, a
|
|
* malicious one probing every escape hatch, and one that simply hangs. They
|
|
* speak the raw `dorfteich.plugin.rpc/1` protocol on purpose — no SDK import —
|
|
* so the assets stay self-contained and keep working even if the SDK bundle
|
|
* format changes.
|
|
*/
|
|
|
|
export function pluginZip(manifest: Record<string, unknown>, bundleSource: string): Buffer {
|
|
return Buffer.from(
|
|
zipSync({
|
|
'manifest.json': strToU8(JSON.stringify(manifest)),
|
|
'plugin.js': strToU8(bundleSource),
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function fixtureManifest(
|
|
id: string,
|
|
name: string,
|
|
permissions: string[],
|
|
): Record<string, unknown> {
|
|
return {
|
|
id,
|
|
name,
|
|
version: '1.0.0',
|
|
apiVersion: '1',
|
|
kind: 'code',
|
|
extensionPoints: [{ type: 'pageTool', id: 'main', title: { de: name, en: name } }],
|
|
permissions,
|
|
fallback: { type: 'text', value: `[${name}]` },
|
|
license: 'MIT',
|
|
};
|
|
}
|
|
|
|
/** Answers `render`, shows a marker, and resizes its frame via `ui.resize`. */
|
|
export const WELL_BEHAVED_SOURCE = `
|
|
const PROTOCOL = 'dorfteich.plugin.rpc/1';
|
|
window.addEventListener('message', (event) => {
|
|
const msg = event.data;
|
|
if (!msg || msg.protocol !== PROTOCOL || msg.type !== 'request') return;
|
|
if (msg.method === 'render') {
|
|
document.body.textContent = 'plugin-ok';
|
|
window.parent.postMessage(
|
|
{ protocol: PROTOCOL, type: 'response', id: msg.id, ok: true },
|
|
'*',
|
|
);
|
|
window.parent.postMessage(
|
|
{ protocol: PROTOCOL, type: 'request', id: 'resize-1', method: 'resize', params: 321 },
|
|
'*',
|
|
);
|
|
}
|
|
});
|
|
`;
|
|
|
|
/**
|
|
* Probes every boundary the sandbox must hold: parent DOM, cookies, storage,
|
|
* same-origin and cross-origin network, and an undeclared capability. Each
|
|
* verdict lands in the frame DOM as
|
|
* `<div data-probe="<name>" data-result="blocked|LEAKED">` for the test to
|
|
* read — a LEAKED value is a security regression.
|
|
*/
|
|
export const MALICIOUS_SOURCE = `
|
|
const PROTOCOL = 'dorfteich.plugin.rpc/1';
|
|
|
|
function report(name, result) {
|
|
const el = document.createElement('div');
|
|
el.dataset.probe = name;
|
|
el.dataset.result = result;
|
|
document.body.appendChild(el);
|
|
}
|
|
|
|
function probeSync(name, attempt) {
|
|
try {
|
|
attempt();
|
|
report(name, 'LEAKED');
|
|
} catch {
|
|
report(name, 'blocked');
|
|
}
|
|
}
|
|
|
|
async function probeFetch(name, url) {
|
|
try {
|
|
await fetch(url);
|
|
report(name, 'LEAKED');
|
|
} catch {
|
|
report(name, 'blocked');
|
|
}
|
|
}
|
|
|
|
function probeUndeclaredCapability() {
|
|
return new Promise((resolve) => {
|
|
const id = 'undeclared-1';
|
|
const timer = setTimeout(() => {
|
|
report('undeclaredCapability', 'LEAKED');
|
|
resolve();
|
|
}, 3000);
|
|
window.addEventListener('message', function onReply(event) {
|
|
const msg = event.data;
|
|
if (!msg || msg.protocol !== PROTOCOL || msg.type !== 'response' || msg.id !== id) return;
|
|
window.removeEventListener('message', onReply);
|
|
clearTimeout(timer);
|
|
const rejected = !msg.ok && msg.error && msg.error.code === 'capability_not_permitted';
|
|
report('undeclaredCapability', rejected ? 'blocked' : 'LEAKED');
|
|
resolve();
|
|
});
|
|
window.parent.postMessage(
|
|
{ protocol: PROTOCOL, type: 'request', id, method: 'listPages' },
|
|
'*',
|
|
);
|
|
});
|
|
}
|
|
|
|
window.addEventListener('message', async (event) => {
|
|
const msg = event.data;
|
|
if (!msg || msg.protocol !== PROTOCOL || msg.type !== 'request') return;
|
|
if (msg.method !== 'render') return;
|
|
window.parent.postMessage(
|
|
{ protocol: PROTOCOL, type: 'response', id: msg.id, ok: true },
|
|
'*',
|
|
);
|
|
probeSync('parentDom', () => window.parent.document.title);
|
|
probeSync('cookie', () => document.cookie);
|
|
probeSync('localStorage', () => window.localStorage.getItem('x'));
|
|
await probeFetch('fetchSameOrigin', '/api/v1/healthz');
|
|
await probeFetch('fetchExternal', 'https://example.com/');
|
|
await probeUndeclaredCapability();
|
|
report('done', 'blocked');
|
|
});
|
|
`;
|
|
|
|
/** Loads fine but never answers anything — must hit the timeout placeholder. */
|
|
export const HUNG_SOURCE = `
|
|
window.addEventListener('message', () => {
|
|
// Deliberately silent: the host deadline must handle this plugin.
|
|
});
|
|
`;
|