dorfteich/apps/api/src/plugins/plugin-frame.ts
Claude Fable 5 75ec7f6006 CSP: data:-Fonts erlauben (eingebettete Fonts in Excalidraw-SVGs)
Excalidraw bettet beim Speichern die verwendeten Schrift-Subsets als
data:-URIs ins Snapshot-SVG ein. Die Seiten-CSP (nginx) und die
Plugin-Frame-CSP erlaubten aber nur `font-src 'self'` bzw. den
Asset-Pfad — die Handschrift fiel in der öffentlichen Ansicht und im
Snapshot-Render auf Serifen zurück (auf Prod an der ersten Demo-Skizze
sichtbar). Fix: `data:` in beiden font-src-Direktiven. data:-Fonts
lösen keinerlei Netzwerk-Request aus — die Zero-Third-Party-Garantie
(security.md) bleibt unberührt; fonts.ts-Kommentar entsprechend
präzisiert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-19 22:27:17 +02:00

86 lines
3.5 KiB
TypeScript

/**
* 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:`,
// `data:` so a saved sketch SVG with embedded (subsetted) fonts — the
// Excalidraw plugin stores those — renders its handwriting look in the
// snapshot view too. data: fonts make no network request.
`font-src ${assetBase} data:`,
// A plugin may talk to its OWN version-pinned assets (bundled apps like
// drawio lazy-load stencils/resources via XHR) — and to nothing else:
// no api, no external hosts. The zero-external-network guarantee holds.
`connect-src ${assetBase}`,
// Bundled sub-apps may run in a child frame of the plugin's own assets
// (the drawio editor); the child inherits the sandbox attribute and,
// being served from the same asset path, this same CSP (the asset
// controller stamps it on every text/html asset).
`frame-src ${assetBase}`,
`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;');
}