Add the sandbox host runtime for plugin iframes (#73)
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
This commit is contained in:
Claude Fable 5 2026-07-11 09:16:30 +02:00
parent 6e56403102
commit 0875e2a087
23 changed files with 1014 additions and 7 deletions

View File

@ -332,6 +332,11 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/search.spec.ts
- name: Run plugin sandbox pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/plugins.spec.ts
- name: Dump server logs on failure
if: failure()
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true

View File

@ -10,7 +10,9 @@ import {
import type { Request, Response } from 'express';
import { Public } from '../auth/auth.guard';
import { AppConfig } from '../config/app-config.service';
import { buildPluginAssetBase, buildPluginFrameCsp, buildPluginFrameHtml } from './plugin-frame';
import { PluginStorageService } from './plugin-storage.service';
import { PluginsService } from './plugins.service';
@ -47,8 +49,37 @@ export class PluginAssetsController {
constructor(
private readonly plugins: PluginsService,
private readonly storage: PluginStorageService,
private readonly config: AppConfig,
) {}
/**
* The sandbox frame document (#73). Declared before the asset wildcard so
* the static segment wins. The CSP pins every load to this plugin's asset
* path and blocks all network access; see plugin-frame.ts for the rationale.
*/
@Get(':id/:version/frame')
@Public()
async frame(
@Param('id') id: string,
@Param('version') version: string,
@Res({ passthrough: true }) response: Response,
): Promise<string> {
const plugin = await this.plugins.get(id);
if (!plugin || plugin.version !== version) throw new NotFoundException();
// Asset base is built from the configured public origin, not the request
// Host header: a proxy that rewrites Host (the vite dev proxy does) must
// not be able to produce a CSP that blocks the plugin's own bundle.
const origin = new URL(this.config.env.APP_BASE_URL).origin;
const assetBase = buildPluginAssetBase(origin, plugin.id, plugin.version);
response.set('Content-Security-Policy', buildPluginFrameCsp(assetBase));
response.set('X-Content-Type-Options', 'nosniff');
response.set('Cache-Control', 'public, max-age=31536000, immutable');
response.type('text/html; charset=utf-8');
return buildPluginFrameHtml(plugin.name);
}
@Get(':id/:version/*rest')
@Public()
async serve(
@ -69,6 +100,10 @@ export class PluginAssetsController {
response.set('X-Content-Type-Options', 'nosniff');
response.set('Cache-Control', 'public, max-age=31536000, immutable');
// The sandbox frame has a null (opaque) origin, so it fetches its own
// bundle cross-origin; allow it. These are public, immutable client
// assets, never user data — a wildcard is safe.
response.set('Access-Control-Allow-Origin', '*');
return new StreamableFile(this.storage.createAssetReadStream(full), {
type: contentTypeFor(relPath),
});

View File

@ -0,0 +1,74 @@
/**
* The sandbox frame document for a code plugin (ADR 0008, issue #73).
*
* The host app embeds `<iframe sandbox="allow-scripts" src=".../frame">`; this
* module builds that document and its Content-Security-Policy. Because the
* frame runs with an opaque origin (`allow-same-origin` is never granted),
* CSP `'self'` would match nothing every source must be spelled out as a
* host-source. The policy pins all loads to the plugin's own version-pinned
* asset path and forbids network access entirely (`connect-src 'none'`,
* plugin-architecture.md §Sandbox runtime).
*/
/** Path the frame document lives under, relative to the plugin version root. */
export const PLUGIN_FRAME_PATH = 'frame';
/**
* The CSP for one plugin frame. `assetBase` is the plugin's asset directory as
* an absolute CSP host-source, e.g.
* `https://test.dorfteich.cloud/api/v1/plugins/toc/1.2.0/`. It must carry the
* scheme and the public origin the browser actually loaded the frame from
* `'self'` cannot be used because the sandboxed frame has an opaque origin
* that `'self'` never matches. Build it from the configured public base URL
* (`buildPluginAssetBase`), not the request `Host` header, so a dev/stage
* proxy that rewrites `Host` cannot break the policy.
*/
export function buildPluginFrameCsp(assetBase: string): string {
return [
`default-src 'none'`,
`script-src ${assetBase}`,
`style-src ${assetBase} 'unsafe-inline'`,
`img-src ${assetBase} data: blob:`,
`font-src ${assetBase}`,
`connect-src 'none'`,
`base-uri 'none'`,
`form-action 'none'`,
].join('; ');
}
/** The absolute asset base a plugin's frame loads from, built from the public
* base URL origin: `<origin>/api/v1/plugins/<id>/<version>/`. */
export function buildPluginAssetBase(baseUrlOrigin: string, id: string, version: string): string {
return `${baseUrlOrigin}/api/v1/plugins/${id}/${version}/`;
}
/**
* The minimal HTML document the sandbox loads. It carries no inline script
* (the CSP above forbids it); the plugin bundle is the only executable code
* and is resolved relative to the frame URL, i.e. from the same version-pinned
* asset directory.
*/
export function buildPluginFrameHtml(pluginName: string): string {
const title = escapeHtml(pluginName);
return [
'<!doctype html>',
'<html>',
'<head>',
'<meta charset="utf-8">',
`<title>${title}</title>`,
'<style>html,body{margin:0;padding:0}</style>',
'</head>',
'<body>',
'<script type="module" src="./plugin.js"></script>',
'</body>',
'</html>',
].join('\n');
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}

View File

@ -119,6 +119,29 @@ describe.skipIf(!hasTestDb)('plugins install (e2e, issue #71)', () => {
expect(list.body.map((p: { id: string }) => p.id)).toContain('toc');
});
it('serves the sandbox frame document with a network-denying CSP (issue #73)', async () => {
await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(codeManifest({ id: 'framer', name: 'Framer' })), 'framer.zip')
.expect(201);
const frame = await api().get('/api/v1/plugins/framer/1.0.0/frame').expect(200);
expect(frame.headers['content-type']).toContain('text/html');
expect(frame.text).toContain('<script type="module" src="./plugin.js">');
// The CSP pins every load to the plugin's own asset path on the configured
// public origin (not the request Host) and forbids network access — the
// sandbox security core.
const csp = frame.headers['content-security-policy'];
expect(csp).toContain(`default-src 'none'`);
expect(csp).toMatch(/script-src https?:\/\/[^ ]+\/api\/v1\/plugins\/framer\/1\.0\.0\//);
expect(csp).toContain(`connect-src 'none'`);
// Only the installed current version has a frame; anything else 404s.
await api().get('/api/v1/plugins/framer/9.9.9/frame').expect(404);
await api().get('/api/v1/plugins/ghost/1.0.0/frame').expect(404);
});
it('forbids install for non-Site-Admins', async () => {
await api()
.post('/api/v1/admin/plugins')

View File

@ -7,13 +7,16 @@ WORKDIR /repo
RUN npm install -g pnpm@11
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
COPY packages/shared ./packages/shared
COPY packages/plugin-sdk ./packages/plugin-sdk
COPY apps/web ./apps/web
COPY deploy/fonts ./deploy/fonts
# Build shared, then download the catalog fonts into the web app (ADR 0016:
# self-hosted, baked into the image — never fetched from a visitor's browser),
# then build. The font step fails the image build if a family lacks license info.
# Build the workspace deps (shared + plugin-sdk), then download the catalog
# fonts into the web app (ADR 0016: self-hosted, baked into the image — never
# fetched from a visitor's browser), then build. The font step fails the image
# build if a family lacks license info.
RUN pnpm install --frozen-lockfile --filter @dorfteich/web... \
&& pnpm --filter @dorfteich/shared build \
&& pnpm --filter @dorfteich/plugin-sdk build \
&& node deploy/fonts/build-fonts.mjs \
&& VITE_APP_VERSION=${APP_VERSION} pnpm --filter @dorfteich/web build

View File

@ -70,6 +70,25 @@ _write_ on something readable is 403.
one place the policy is pinned. A weakened guard is caught here — verified by
temporarily loosening a route decorator and watching the pack go red.
## Plugin sandbox (`plugins.spec.ts`, issue #73)
The sandbox security pack drives the Site-Admin plugin preview page
(`/admin/plugins/:id/preview`) — the exact sandbox runtime pages embed — with
three fixture plugins built in `plugin-fixtures.ts` and installed through the
admin API:
- **well-behaved**: answers `render` and resizes its own frame via the declared
`ui` capability;
- **malicious** (the permanent security regression asset, ADR 0008): probes the
parent DOM, cookies, `localStorage`, same-origin and external `fetch`, and an
undeclared capability — every probe must report `blocked`. A `LEAKED` verdict
is a sandbox escape and fails the build;
- **hung**: never answers, so the host's 5 s deadline must collapse it to the
failure placeholder while the surrounding page stays responsive.
Runs in the `auth-e2e` CI job (needs the api's writable `PLUGINS_DIR`, satisfied
by the default `./data/plugins`).
## Attachments (`attachments.spec.ts`, issue #61)
Non-image attachments: a page's attachments section uploads an allowlisted

View File

@ -0,0 +1,140 @@
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.
});
`;

View File

@ -0,0 +1,106 @@
import { expect, test } from '@playwright/test';
import type { BrowserContext } from '@playwright/test';
import { contextForUser } from './helpers';
import {
fixtureManifest,
HUNG_SOURCE,
MALICIOUS_SOURCE,
pluginZip,
WELL_BEHAVED_SOURCE,
} from './plugin-fixtures';
/**
* Sandbox security pack (issue #73, ADR 0008): drives the admin plugin
* preview page the same sandbox runtime pages embed with a well-behaved,
* a malicious, and a hung fixture plugin. The malicious plugin is the
* permanent security regression asset: every probe it runs must stay
* "blocked" forever.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
async function installPlugin(
context: BrowserContext,
id: string,
name: string,
permissions: string[],
source: string,
): Promise<void> {
// Idempotent per run: remove any previous installation of the fixture id.
await context.request.delete(`/api/v1/admin/plugins/${id}`);
const response = await context.request.post('/api/v1/admin/plugins', {
multipart: {
file: {
name: `${id}.zip`,
mimeType: 'application/zip',
buffer: pluginZip(fixtureManifest(id, name, permissions), source),
},
},
});
expect(response.status(), await response.text()).toBe(201);
}
test('a well-behaved plugin renders and resizes its own frame', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-admin');
await installPlugin(context, 'e2e-behaved', 'Behaved', ['ui'], WELL_BEHAVED_SOURCE);
const page = await context.newPage();
await page.goto('/admin/plugins/e2e-behaved/preview');
const host = page.locator('.plugin-frame-host');
await expect(host).toHaveAttribute('data-state', 'ready', { timeout: 10000 });
const frame = page.frameLocator('.plugin-frame');
await expect(frame.locator('body')).toHaveText('plugin-ok');
// `ui.resize` was declared → the built-in handler applied the height.
await expect(page.locator('.plugin-frame')).toHaveCSS('height', '321px');
});
test('a malicious plugin cannot escape the sandbox (security regression asset)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-admin');
await installPlugin(context, 'e2e-malicious', 'Malicious', [], MALICIOUS_SOURCE);
const page = await context.newPage();
await page.goto('/admin/plugins/e2e-malicious/preview');
const frame = page.frameLocator('.plugin-frame');
// The final probe reports last — once it is attached, all verdicts are in.
// (The marker divs are empty and zero-size, so assert attachment, not
// visibility.)
await expect(frame.locator('[data-probe="done"]')).toBeAttached({ timeout: 15000 });
for (const probe of [
'parentDom',
'cookie',
'localStorage',
'fetchSameOrigin',
'fetchExternal',
'undeclaredCapability',
]) {
await expect(frame.locator(`[data-probe="${probe}"]`), probe).toHaveAttribute(
'data-result',
'blocked',
);
}
});
test('a hung plugin collapses to the timeout placeholder; the page stays responsive', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-admin');
await installPlugin(context, 'e2e-hung', 'Hung', [], HUNG_SOURCE);
const page = await context.newPage();
await page.goto('/admin/plugins/e2e-hung/preview');
const host = page.locator('.plugin-frame-host');
// The placeholder must appear within the 5 s deadline (plus render slack).
await expect(host).toHaveAttribute('data-state', 'failed', { timeout: 7000 });
await expect(page.locator('.plugin-frame-host__status--failed')).toBeVisible();
await expect(page.locator('.plugin-frame')).toHaveCount(0);
// The app around the dead plugin still responds to input.
await expect(page.locator('.plugin-preview__permissions, h1').first()).toBeVisible();
await page.getByRole('heading', { level: 1 }).click();
});

View File

@ -14,6 +14,7 @@
"e2e": "playwright test"
},
"dependencies": {
"@dorfteich/plugin-sdk": "workspace:*",
"@dorfteich/shared": "workspace:*",
"@hocuspocus/provider": "^4.3.0",
"@hookform/resolvers": "^5.4.0",
@ -40,6 +41,7 @@
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"fflate": "^0.8.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",

View File

@ -7,6 +7,7 @@ import { FontCatalogPage } from './pages/FontCatalogPage';
import { HomePage } from './pages/HomePage';
import { NotFoundPage } from './pages/NotFoundPage';
import { PageEditorPage } from './pages/PageEditorPage';
import { PluginPreviewPage } from './pages/PluginPreviewPage';
import { PondHomePage } from './pages/PondHomePage';
import { PondSettingsPage } from './pages/PondSettingsPage';
import { PublicPageView } from './pages/PublicPageView';
@ -49,6 +50,8 @@ export function App(): React.JSX.Element {
</Route>
<Route element={<RequireSiteAdmin />}>
<Route path="admin" element={<AdminSettingsPage />} />
{/* Sandbox preview of one installed plugin (issue #73). */}
<Route path="admin/plugins/:pluginId/preview" element={<PluginPreviewPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />

View File

@ -10,6 +10,7 @@ import deImport from '@dorfteich/shared/i18n/de/import.json';
import deLabels from '@dorfteich/shared/i18n/de/labels.json';
import deLinks from '@dorfteich/shared/i18n/de/links.json';
import deMembers from '@dorfteich/shared/i18n/de/members.json';
import dePlugins from '@dorfteich/shared/i18n/de/plugins.json';
import dePublic from '@dorfteich/shared/i18n/de/public.json';
import deQuotas from '@dorfteich/shared/i18n/de/quotas.json';
import deSearch from '@dorfteich/shared/i18n/de/search.json';
@ -27,6 +28,7 @@ import enImport from '@dorfteich/shared/i18n/en/import.json';
import enLabels from '@dorfteich/shared/i18n/en/labels.json';
import enLinks from '@dorfteich/shared/i18n/en/links.json';
import enMembers from '@dorfteich/shared/i18n/en/members.json';
import enPlugins from '@dorfteich/shared/i18n/en/plugins.json';
import enPublic from '@dorfteich/shared/i18n/en/public.json';
import enQuotas from '@dorfteich/shared/i18n/en/quotas.json';
import enSearch from '@dorfteich/shared/i18n/en/search.json';
@ -61,6 +63,7 @@ void i18n
labels: enLabels,
links: enLinks,
members: enMembers,
plugins: enPlugins,
public: enPublic,
quotas: enQuotas,
search: enSearch,
@ -80,6 +83,7 @@ void i18n
labels: deLabels,
links: deLinks,
members: deMembers,
plugins: dePlugins,
public: dePublic,
quotas: deQuotas,
search: deSearch,

View File

@ -0,0 +1,76 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import type { PluginView } from '@dorfteich/shared';
import { apiGet } from '../lib/api';
import { PluginFrame } from '../plugins/PluginFrame';
/**
* Site-Admin preview of one installed plugin (issue #73): mounts each of the
* plugin's extension points in a real sandbox frame, with the declared
* permissions listed next to it. Doubles as the surface the plugin security
* e2e pack drives the sandbox here is byte-identical to what pages embed.
*/
export function PluginPreviewPage(): React.JSX.Element {
const { t } = useTranslation('plugins');
const { pluginId } = useParams<{ pluginId: string }>();
const [plugins, setPlugins] = useState<PluginView[] | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
apiGet<PluginView[]>('/admin/plugins')
.then((list) => {
if (!cancelled) setPlugins(list);
})
.catch(() => {
if (!cancelled) setError(true);
});
return () => {
cancelled = true;
};
}, []);
const plugin = useMemo(
() => plugins?.find((entry) => entry.id === pluginId) ?? null,
[plugins, pluginId],
);
if (error) return <p role="alert">{t('preview.loadFailed')}</p>;
if (!plugins) return <p>{t('preview.loading')}</p>;
if (!plugin) return <p role="alert">{t('preview.notFound')}</p>;
return (
<div className="plugin-preview">
<h1>
{t('preview.title', { name: plugin.name })}{' '}
<span className="plugin-preview__version">v{plugin.version}</span>
</h1>
<section>
<h2>{t('preview.permissions')}</h2>
{plugin.permissions.length === 0 ? (
<p>{t('preview.noPermissions')}</p>
) : (
<ul className="plugin-preview__permissions">
{plugin.permissions.map((permission) => (
<li key={permission}>
<code>{permission}</code>
</li>
))}
</ul>
)}
</section>
{plugin.extensionPoints.map((point) => (
<section key={point.id} className="plugin-preview__surface">
<h2>
{t('preview.surface')} <code>{point.id}</code> ({point.type})
</h2>
<PluginFrame plugin={plugin} extensionPointId={point.id} />
</section>
))}
{plugin.extensionPoints.length === 0 ? <p>{t('preview.noSurfaces')}</p> : null}
</div>
);
}

View File

@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { HostCapabilityHandlers } from '@dorfteich/plugin-sdk';
import { createPluginSandbox, type SandboxPluginRef, type SandboxState } from './sandbox-host';
export interface PluginFrameProps {
plugin: SandboxPluginRef;
extensionPointId: string;
/** Host capability implementations for this surface (grows with #74). */
capabilities?: HostCapabilityHandlers;
/** Block data for block surfaces (#76). */
data?: unknown;
}
/**
* Mounts one sandboxed plugin surface (issue #73). The sandbox lifecycle is
* bound to the component: unmounting (navigation, panel close) tears the
* frame down. A plugin that fails or hangs collapses to a placeholder the
* page around it stays fully responsive.
*/
export function PluginFrame({
plugin,
extensionPointId,
capabilities,
data,
}: PluginFrameProps): React.JSX.Element {
const { t, i18n } = useTranslation('plugins');
const containerRef = useRef<HTMLDivElement | null>(null);
const [state, setState] = useState<SandboxState>('loading');
useEffect(() => {
const container = containerRef.current;
if (!container) return undefined;
setState('loading');
const sandbox = createPluginSandbox({
plugin,
extensionPointId,
locale: i18n.language,
container,
capabilities,
data,
onStateChange: setState,
});
return () => sandbox.destroy();
// `capabilities`/`data` identity is owned by the parent surface; remounting
// on plugin/extension change is what resets the sandbox.
}, [plugin.id, plugin.version, extensionPointId, i18n.language, capabilities, data]);
return (
<div className="plugin-frame-host" data-plugin-id={plugin.id} data-state={state}>
<div ref={containerRef} className="plugin-frame-host__mount" />
{state === 'loading' ? (
<p className="plugin-frame-host__status">{t('frame.loading')}</p>
) : null}
{state === 'failed' ? (
<p className="plugin-frame-host__status plugin-frame-host__status--failed" role="note">
{t('frame.failed', { name: plugin.name })}
</p>
) : null}
</div>
);
}

View File

@ -0,0 +1,29 @@
import type { RpcMessage, RpcTransport } from '@dorfteich/plugin-sdk';
/**
* RPC transport bound to one sandbox iframe (ADR 0008, issue #73).
*
* Outgoing messages go to the frame's window; `targetOrigin '*'` is the only
* option for an opaque-origin sandbox (it has no origin to pin), and is safe
* because the payload never contains secrets capability *results* flow only
* after the gate approved the call.
*
* Incoming messages are accepted **only** when `event.source` is this exact
* frame's window: another plugin frame (or any other window) on the page must
* not be able to spoof requests or responses on this channel.
*/
export function frameTransport(frame: HTMLIFrameElement): RpcTransport {
return {
post: (message) => {
frame.contentWindow?.postMessage(message, '*');
},
listen: (onMessage) => {
const listener = (event: MessageEvent): void => {
if (event.source === null || event.source !== frame.contentWindow) return;
onMessage(event.data as RpcMessage);
};
window.addEventListener('message', listener);
return () => window.removeEventListener('message', listener);
},
};
}

View File

@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import type { RpcMessage } from '@dorfteich/plugin-sdk';
import { frameTransport } from './frame-transport';
import { createPluginSandbox, type SandboxState } from './sandbox-host';
const PLUGIN = {
id: 'fixture',
version: '1.0.0',
name: 'Fixture plugin',
permissions: ['ui'],
assetBasePath: '/api/v1/plugins/fixture/1.0.0/',
};
function makeFrame(): HTMLIFrameElement {
const frame = document.createElement('iframe');
document.body.appendChild(frame);
return frame;
}
describe('frameTransport', () => {
it('delivers only messages whose source is the bound frame', () => {
const frame = makeFrame();
const otherFrame = makeFrame();
const received: RpcMessage[] = [];
const transport = frameTransport(frame);
const unlisten = transport.listen((message) => received.push(message));
const payload = { protocol: 'dorfteich.plugin.rpc/1', type: 'request' } as RpcMessage;
// Spoof attempts: no source (default), and a *different* frame's window.
window.dispatchEvent(new MessageEvent('message', { data: payload }));
window.dispatchEvent(
new MessageEvent('message', { data: payload, source: otherFrame.contentWindow }),
);
expect(received).toHaveLength(0);
// The genuine frame gets through.
window.dispatchEvent(
new MessageEvent('message', { data: payload, source: frame.contentWindow }),
);
expect(received).toHaveLength(1);
unlisten();
window.dispatchEvent(
new MessageEvent('message', { data: payload, source: frame.contentWindow }),
);
expect(received).toHaveLength(1);
});
});
describe('createPluginSandbox', () => {
it('creates an opaque-origin frame: allow-scripts only, never allow-same-origin', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const sandbox = createPluginSandbox({
plugin: PLUGIN,
extensionPointId: 'main',
locale: 'de',
container,
});
expect(sandbox.iframe.getAttribute('sandbox')).toBe('allow-scripts');
expect(sandbox.iframe.src).toContain('/api/v1/plugins/fixture/1.0.0/frame');
expect(container.querySelector('iframe')).toBe(sandbox.iframe);
sandbox.destroy();
expect(container.querySelector('iframe')).toBeNull();
});
it('fails and removes the frame when the plugin never answers within the deadline', async () => {
vi.useFakeTimers();
try {
const container = document.createElement('div');
document.body.appendChild(container);
const states: SandboxState[] = [];
createPluginSandbox({
plugin: PLUGIN,
extensionPointId: 'main',
locale: 'de',
container,
renderTimeoutMs: 5000,
onStateChange: (state) => states.push(state),
});
// jsdom never loads the src and no plugin ever answers — the hard
// deadline alone must collapse the surface to the failed state.
await vi.advanceTimersByTimeAsync(4999);
expect(states).toEqual([]);
await vi.advanceTimersByTimeAsync(2);
expect(states).toEqual(['failed']);
expect(container.querySelector('iframe')).toBeNull();
} finally {
vi.useRealTimers();
}
});
});

View File

@ -0,0 +1,162 @@
/**
* Host runtime for one sandboxed plugin surface (ADR 0008, issue #73).
*
* Creates the opaque-origin iframe (`sandbox="allow-scripts"`, never
* `allow-same-origin`), wires the SDK host bridge over a source-filtered
* transport, drives the render lifecycle under a hard deadline, and tears
* everything down on destroy. A plugin that misbehaves never loads, never
* answers, calls undeclared capabilities degrades to a placeholder state
* without ever blocking the page.
*/
import {
createHostBridge,
type HostBridge,
type HostCapabilityHandlers,
type PluginLifecycleMethod,
type RenderContext,
type RpcRequestOptions,
} from '@dorfteich/plugin-sdk';
import { frameTransport } from './frame-transport';
/** Deadline for the frame to load and answer its first `render` call. */
export const RENDER_TIMEOUT_MS = 5000;
/** Bounds for plugin-requested frame heights (`ui.resize`). */
const MIN_FRAME_HEIGHT_PX = 16;
const MAX_FRAME_HEIGHT_PX = 6000;
export type SandboxState = 'loading' | 'ready' | 'failed';
/** The subset of an installed plugin the sandbox needs to host a surface. */
export interface SandboxPluginRef {
id: string;
version: string;
name: string;
/** Declared manifest capabilities — the host bridge gates against these. */
permissions: string[];
/** `/api/v1/plugins/<id>/<version>/` (shared `PluginView.assetBasePath`). */
assetBasePath: string;
}
export interface CreateSandboxOptions {
plugin: SandboxPluginRef;
extensionPointId: string;
locale: string;
/** Element the iframe is appended to. */
container: HTMLElement;
/** Host implementations of capability methods (grows with #74). The `ui`
* method `resize` has a built-in default; pass your own to override. */
capabilities?: HostCapabilityHandlers;
/** Block data passed to `render`/`edit` (block surfaces, #76). */
data?: unknown;
renderTimeoutMs?: number;
onStateChange?: (state: SandboxState) => void;
}
export interface PluginSandbox {
readonly iframe: HTMLIFrameElement;
/** Calls a plugin lifecycle method (`render`, `edit`, `destroy`). */
invoke: <T = unknown>(
method: PluginLifecycleMethod,
params?: unknown,
options?: RpcRequestOptions,
) => Promise<T>;
/** Removes the frame and rejects in-flight calls. Idempotent. */
destroy: () => void;
}
export function createPluginSandbox(options: CreateSandboxOptions): PluginSandbox {
const timeoutMs = options.renderTimeoutMs ?? RENDER_TIMEOUT_MS;
let state: SandboxState = 'loading';
let destroyed = false;
const iframe = document.createElement('iframe');
// The security core: scripts may run, but the frame gets an opaque origin —
// no cookies, no storage, no parent DOM (verified by the e2e security pack).
iframe.setAttribute('sandbox', 'allow-scripts');
iframe.className = 'plugin-frame';
iframe.title = options.plugin.name;
iframe.src = `${options.plugin.assetBasePath}frame`;
const setState = (next: SandboxState): void => {
if (state === next || destroyed) return;
state = next;
options.onStateChange?.(next);
};
const capabilities: HostCapabilityHandlers = {
// Built-in `ui.resize`: plugins size their own frame, clamped so a hostile
// plugin cannot blow up the layout. Still gated: only manifests declaring
// the `ui` capability ever reach this handler.
resize: (params) => {
const height = typeof params === 'number' ? params : Number.NaN;
if (!Number.isFinite(height)) return;
const clamped = Math.min(MAX_FRAME_HEIGHT_PX, Math.max(MIN_FRAME_HEIGHT_PX, height));
iframe.style.height = `${clamped}px`;
},
...options.capabilities,
};
const bridge: HostBridge = createHostBridge({
manifest: { permissions: options.plugin.permissions as never },
capabilities,
transport: frameTransport(iframe),
timeoutMs,
onViolation: (violation) => {
// A misbehaving plugin must leave a trace (issue #73 AC).
console.warn(
`[plugin:${options.plugin.id}] rejected "${violation.method}" (${violation.code})`,
);
},
});
const fail = (): void => {
setState('failed');
teardown();
};
// One hard deadline covers both a frame that never loads (404, crash) and a
// bundle that loads but never answers `render` — the page must show the
// placeholder within the timeout either way.
const deadline = window.setTimeout(fail, timeoutMs);
const onLoad = (): void => {
const context: RenderContext = {
extensionPointId: options.extensionPointId,
locale: options.locale,
data: options.data,
};
bridge
.invoke('render', context, { timeoutMs })
.then(() => {
window.clearTimeout(deadline);
setState('ready');
})
.catch(() => {
window.clearTimeout(deadline);
fail();
});
};
iframe.addEventListener('load', onLoad);
options.container.appendChild(iframe);
function teardown(): void {
window.clearTimeout(deadline);
iframe.removeEventListener('load', onLoad);
bridge.dispose();
iframe.remove();
}
return {
iframe,
invoke: (method, params, requestOptions) => bridge.invoke(method, params, requestOptions),
destroy: () => {
if (destroyed) return;
// Give the plugin a moment to clean up, but never wait for it.
void bridge.invoke('destroy', undefined, { timeoutMs: 250 }).catch(() => undefined);
destroyed = true;
teardown();
},
};
}

View File

@ -1960,3 +1960,39 @@ button {
color: var(--color-text-muted);
margin-bottom: var(--space-2);
}
/* --- Plugin sandbox frames (issue #73) ------------------------------------ */
.plugin-frame-host__mount .plugin-frame {
display: block;
width: 100%;
height: 120px;
border: none;
background: transparent;
}
.plugin-frame-host[data-state='failed'] .plugin-frame-host__mount {
display: none;
}
.plugin-frame-host__status {
color: var(--color-text-muted);
font-size: 0.875rem;
}
.plugin-frame-host__status--failed {
color: var(--color-danger, #b91c1c);
border: 1px dashed var(--color-danger, #b91c1c);
border-radius: 4px;
padding: var(--space-2);
}
.plugin-preview__version {
color: var(--color-text-muted);
font-size: 1rem;
font-weight: 400;
}
.plugin-preview__surface {
margin-top: var(--space-4);
}

View File

@ -13,6 +13,8 @@ export default tseslint.config(
'**/coverage/**',
'**/.pnpm-store/**',
'**/*.gen.ts',
// Runtime data volumes (installed plugin bundles, uploads) — not source.
'apps/api/data/**',
],
},
js.configs.recommended,

View File

@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest';
import { createHostBridge, type CapabilityViolation } from './host';
import { createRpcEndpoint, type RpcMessage, type RpcTransport } from './rpc';
/** Two directly-wired in-memory transports (host side, plugin side). */
function transportPair(): [RpcTransport, RpcTransport] {
const toA: Array<(message: RpcMessage) => void> = [];
const toB: Array<(message: RpcMessage) => void> = [];
const a: RpcTransport = {
post: (message) => queueMicrotask(() => toB.forEach((listener) => listener(message))),
listen: (onMessage) => {
toA.push(onMessage);
return () => toA.splice(toA.indexOf(onMessage), 1);
},
};
const b: RpcTransport = {
post: (message) => queueMicrotask(() => toA.forEach((listener) => listener(message))),
listen: (onMessage) => {
toB.push(onMessage);
return () => toB.splice(toB.indexOf(onMessage), 1);
},
};
return [a, b];
}
describe('createHostBridge gating', () => {
it('answers declared capabilities, rejects undeclared ones, and reports violations', async () => {
const [hostSide, pluginSide] = transportPair();
const violations: CapabilityViolation[] = [];
const getOutline = vi.fn().mockResolvedValue([{ id: 'h1', level: 1, text: 'Hello' }]);
const bridge = createHostBridge({
manifest: { permissions: ['readCurrentPage'] },
capabilities: { getOutline, listPages: vi.fn() },
transport: hostSide,
onViolation: (violation) => violations.push(violation),
});
const plugin = createRpcEndpoint({ ...pluginSide, timeoutMs: 500 });
// Declared capability → answered by the host implementation.
await expect(plugin.request('getOutline')).resolves.toEqual([
{ id: 'h1', level: 1, text: 'Hello' },
]);
// Undeclared capability → rejected before the implementation, and logged.
await expect(plugin.request('listPages')).rejects.toMatchObject({
code: 'capability_not_permitted',
});
expect(violations).toEqual([
{ method: 'listPages', capability: 'readPond', code: 'capability_not_permitted' },
]);
// A method outside the v1 surface → unknown_method (no handler installed).
await expect(plugin.request('formatHardDrive')).rejects.toMatchObject({
code: 'unknown_method',
});
bridge.dispose();
plugin.dispose();
});
it('refuses to invoke non-lifecycle methods on the plugin', async () => {
const [hostSide] = transportPair();
const bridge = createHostBridge({
manifest: { permissions: [] },
capabilities: {},
transport: hostSide,
});
await expect(
bridge.invoke('getOutline' as never, undefined, { timeoutMs: 100 }),
).rejects.toMatchObject({ code: 'unknown_method' });
bridge.dispose();
});
});

View File

@ -3,7 +3,7 @@
* plugin's capability calls (filtered against the manifest `permissions`), and
* drives the plugin's lifecycle methods.
*/
import { capabilityForMethod, PLUGIN_LIFECYCLE_METHODS } from './capabilities';
import { capabilityForMethod, METHOD_CAPABILITY, PLUGIN_LIFECYCLE_METHODS } from './capabilities';
import type { PluginLifecycleMethod } from './capabilities';
import type { PluginManifest } from './manifest';
import {
@ -23,6 +23,13 @@ import {
*/
export type HostCapabilityHandlers = Partial<Record<string, RpcHandler>>;
/** A capability call the gate refused, reported before the caller is told. */
export interface CapabilityViolation {
method: string;
capability?: string;
code: 'unknown_method' | 'capability_not_permitted';
}
export interface HostBridgeOptions {
manifest: Pick<PluginManifest, 'permissions'>;
/** Host implementations of the plugin API methods. */
@ -30,6 +37,9 @@ export interface HostBridgeOptions {
transport: RpcTransport;
/** Default timeout for host→plugin lifecycle calls, in ms. */
timeoutMs?: number;
/** Called when the gate refuses a call (undeclared capability or unknown
* method) hosts log these; a misbehaving plugin must leave a trace. */
onViolation?: (violation: CapabilityViolation) => void;
}
export interface HostBridge {
@ -67,12 +77,14 @@ export function createHostBridge(options: HostBridgeOptions): HostBridge {
return async (params) => {
const capability = capabilityForMethod(method);
if (!capability) {
options.onViolation?.({ method, code: 'unknown_method' });
throw new RpcErrorObject({
code: 'unknown_method',
message: `"${method}" is not a plugin API method`,
});
}
if (!declared.has(capability)) {
options.onViolation?.({ method, capability, code: 'capability_not_permitted' });
throw new RpcErrorObject({
code: 'capability_not_permitted',
message: `plugin did not declare capability "${capability}" required for "${method}"`,
@ -89,9 +101,12 @@ export function createHostBridge(options: HostBridgeOptions): HostBridge {
};
};
// Register a gated handler for every method the host implements. Methods the
// host omits fall through to the endpoint's `unknown_method` response.
for (const method of Object.keys(options.capabilities)) {
// Register a gated handler for every v1 API method — not just the ones the
// host implements. That way an *undeclared* call is answered with
// `capability_not_permitted` (and reported via `onViolation`) even when the
// host has no implementation for it; only methods outside the v1 surface
// fall through to the endpoint's plain `unknown_method` response.
for (const method of Object.keys(METHOD_CAPABILITY)) {
endpoint.setHandler(method, gate(method));
}

View File

@ -0,0 +1,16 @@
{
"frame": {
"loading": "Plugin wird geladen …",
"failed": "Das Plugin „{{name}}“ konnte nicht geladen werden."
},
"preview": {
"loading": "Plugins werden geladen …",
"loadFailed": "Die Plugin-Liste konnte nicht geladen werden.",
"notFound": "Dieses Plugin ist nicht installiert.",
"title": "Vorschau: {{name}}",
"permissions": "Angeforderte Berechtigungen",
"noPermissions": "Dieses Plugin fordert keine Berechtigungen an.",
"surface": "Oberfläche",
"noSurfaces": "Dieses Plugin stellt keine Oberflächen bereit."
}
}

View File

@ -0,0 +1,16 @@
{
"frame": {
"loading": "Loading plugin …",
"failed": "The plugin “{{name}}” could not be loaded."
},
"preview": {
"loading": "Loading plugins …",
"loadFailed": "The plugin list could not be loaded.",
"notFound": "This plugin is not installed.",
"title": "Preview: {{name}}",
"permissions": "Requested permissions",
"noPermissions": "This plugin requests no permissions.",
"surface": "Surface",
"noSurfaces": "This plugin provides no surfaces."
}
}

6
pnpm-lock.yaml generated
View File

@ -211,6 +211,9 @@ importers:
apps/web:
dependencies:
'@dorfteich/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
'@dorfteich/shared':
specifier: workspace:*
version: link:../../packages/shared
@ -293,6 +296,9 @@ importers:
'@vitejs/plugin-react':
specifier: ^4.3.0
version: 4.7.0(vite@6.4.3(@types/node@26.1.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.0))
fflate:
specifier: ^0.8.2
version: 0.8.3
jsdom:
specifier: ^26.0.0
version: 26.1.0