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, 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 { 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', }; } /** Manifest of a block-type plugin (issue #76): one `block` extension point, * `blockData` to persist, `ui` to resize, and a text fallback for when it is * disabled while its blocks still exist in documents. */ export function blockManifest(id: string, name: string): Record { return { id, name, version: '1.0.0', apiVersion: '1', kind: 'code', extensionPoints: [{ type: 'block', id: 'note', title: { de: name, en: name } }], permissions: ['blockData', 'ui'], fallback: { type: 'text', value: `[${name}]` }, license: 'MIT', }; } /** * Block fixture behavior (issue #76), deterministic for assertions: * - `render` shows `block:`; * - `edit` appends `+e` to the stored text via `blockData.setData` (a real * host round-trip that lands in the node attrs) and shows `editing:`. */ export const BLOCK_PLUGIN_SOURCE = ` const PROTOCOL = 'dorfteich.plugin.rpc/1'; let seq = 0; function respond(id) { window.parent.postMessage({ protocol: PROTOCOL, type: 'response', id, ok: true }, '*'); } function call(method, params) { seq += 1; window.parent.postMessage( { protocol: PROTOCOL, type: 'request', id: 'blk-' + seq, method, params }, '*', ); } window.addEventListener('message', (event) => { const msg = event.data; if (!msg || msg.protocol !== PROTOCOL || msg.type !== 'request') return; const data = (msg.params && msg.params.data) || {}; if (msg.method === 'render') { document.body.textContent = 'block:' + (data.text || 'empty'); respond(msg.id); } else if (msg.method === 'edit') { const next = { text: (data.text || '') + '+e' }; call('setData', next); document.body.textContent = 'editing:' + next.text; respond(msg.id); } else if (msg.method === 'destroy') { respond(msg.id); } }); `; /** 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 * `
` 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. }); `;