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 `