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
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:
parent
6e56403102
commit
0875e2a087
@ -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
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
74
apps/api/src/plugins/plugin-frame.ts
Normal file
74
apps/api/src/plugins/plugin-frame.ts
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
140
apps/web/e2e/plugin-fixtures.ts
Normal file
140
apps/web/e2e/plugin-fixtures.ts
Normal 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.
|
||||
});
|
||||
`;
|
||||
106
apps/web/e2e/plugins.spec.ts
Normal file
106
apps/web/e2e/plugins.spec.ts
Normal 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();
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
@ -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,
|
||||
|
||||
76
apps/web/src/pages/PluginPreviewPage.tsx
Normal file
76
apps/web/src/pages/PluginPreviewPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
apps/web/src/plugins/PluginFrame.tsx
Normal file
64
apps/web/src/plugins/PluginFrame.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
apps/web/src/plugins/frame-transport.ts
Normal file
29
apps/web/src/plugins/frame-transport.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
95
apps/web/src/plugins/sandbox-host.test.ts
Normal file
95
apps/web/src/plugins/sandbox-host.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
162
apps/web/src/plugins/sandbox-host.ts
Normal file
162
apps/web/src/plugins/sandbox-host.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
76
packages/plugin-sdk/src/host.test.ts
Normal file
76
packages/plugin-sdk/src/host.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
16
packages/shared/i18n/de/plugins.json
Normal file
16
packages/shared/i18n/de/plugins.json
Normal 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."
|
||||
}
|
||||
}
|
||||
16
packages/shared/i18n/en/plugins.json
Normal file
16
packages/shared/i18n/en/plugins.json
Normal 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
6
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user