dorfteich/apps/web/e2e/plugin-fixtures.ts
Claude Fable 5 0875e2a087
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
Add the sandbox host runtime for plugin iframes (#73)
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
2026-07-11 09:16:30 +02:00

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.
});
`;