From 0875e2a0873dbf038395982641a0c10f5c608b5f Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 11 Jul 2026 09:16:30 +0200 Subject: [PATCH] Add the sandbox host runtime for plugin iframes (#73) Implements the security core of the plugin system: code-plugin surfaces run in opaque-origin iframes (sandbox="allow-scripts", never allow-same-origin) with a capability-filtered RPC bridge. - api: serve a per-plugin sandbox frame document at /plugins/:id/:version/frame with a CSP that pins every load to the plugin's own asset path (built from APP_BASE_URL, not the request Host, so a Host-rewriting proxy cannot break it) and forbids network access (connect-src 'none'). Plugin assets get Access-Control-Allow-Origin: * so the null-origin frame can load its own module bundle. - web: sandbox-host creates the frame, wires the SDK host bridge over a source-filtered postMessage transport, drives render under a 5 s deadline (hung/failed plugin -> placeholder, never a frozen page), and tears down on unmount. PluginFrame/PluginPreviewPage surface it; the built-in ui.resize handler clamps plugin-requested heights. - plugin-sdk: host bridge reports gate violations via onViolation and registers a gated handler for every v1 method, so an undeclared capability is rejected with capability_not_permitted (not unknown_method). - tests: SDK gate unit test; web sandbox unit tests (opaque origin, source filtering, timeout); and the e2e security pack with a permanent malicious fixture plugin proving no escape (DOM/cookies/storage/fetch/ undeclared capability all blocked) plus well-behaved and hung cases. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- .gitea/workflows/ci.yml | 5 + .../src/plugins/plugin-assets.controller.ts | 35 ++++ apps/api/src/plugins/plugin-frame.ts | 74 ++++++++ apps/api/src/plugins/plugins.e2e.db.test.ts | 23 +++ apps/web/Dockerfile | 9 +- apps/web/e2e/README.md | 19 ++ apps/web/e2e/plugin-fixtures.ts | 140 +++++++++++++++ apps/web/e2e/plugins.spec.ts | 106 ++++++++++++ apps/web/package.json | 2 + apps/web/src/App.tsx | 3 + apps/web/src/i18n/index.ts | 4 + apps/web/src/pages/PluginPreviewPage.tsx | 76 ++++++++ apps/web/src/plugins/PluginFrame.tsx | 64 +++++++ apps/web/src/plugins/frame-transport.ts | 29 ++++ apps/web/src/plugins/sandbox-host.test.ts | 95 ++++++++++ apps/web/src/plugins/sandbox-host.ts | 162 ++++++++++++++++++ apps/web/src/styles/base.css | 36 ++++ eslint.config.mjs | 2 + packages/plugin-sdk/src/host.test.ts | 76 ++++++++ packages/plugin-sdk/src/host.ts | 23 ++- packages/shared/i18n/de/plugins.json | 16 ++ packages/shared/i18n/en/plugins.json | 16 ++ pnpm-lock.yaml | 6 + 23 files changed, 1014 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/plugins/plugin-frame.ts create mode 100644 apps/web/e2e/plugin-fixtures.ts create mode 100644 apps/web/e2e/plugins.spec.ts create mode 100644 apps/web/src/pages/PluginPreviewPage.tsx create mode 100644 apps/web/src/plugins/PluginFrame.tsx create mode 100644 apps/web/src/plugins/frame-transport.ts create mode 100644 apps/web/src/plugins/sandbox-host.test.ts create mode 100644 apps/web/src/plugins/sandbox-host.ts create mode 100644 packages/plugin-sdk/src/host.test.ts create mode 100644 packages/shared/i18n/de/plugins.json create mode 100644 packages/shared/i18n/en/plugins.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b745983..a899744 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/apps/api/src/plugins/plugin-assets.controller.ts b/apps/api/src/plugins/plugin-assets.controller.ts index 8e35543..e9b50d7 100644 --- a/apps/api/src/plugins/plugin-assets.controller.ts +++ b/apps/api/src/plugins/plugin-assets.controller.ts @@ -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 { + 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), }); diff --git a/apps/api/src/plugins/plugin-frame.ts b/apps/api/src/plugins/plugin-frame.ts new file mode 100644 index 0000000..4ef150a --- /dev/null +++ b/apps/api/src/plugins/plugin-frame.ts @@ -0,0 +1,74 @@ +/** + * The sandbox frame document for a code plugin (ADR 0008, issue #73). + * + * The host app embeds `