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
86 lines
3.5 KiB
TypeScript
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('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"');
|
|
}
|