From 855200bcbdeb998dafff821cdb26dc84e89ba02b Mon Sep 17 00:00:00 2001 From: Matt Jennings Date: Tue, 8 Sep 2026 16:29:07 -0500 Subject: [PATCH] feat(publisher): site-level CSP allowlist for third-party script origins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Published pages lock `script-src` to `'self'`, and nothing could lift it: plugin `frontend.assets[]` are same-origin only and `networkAllowedHosts` reaches `connect-src`, not `script-src`. Any site that needs a Google Analytics / Tag Manager loader, the Meta pixel, or a chat widget therefore ships with those tags silently blocked (#227). Add `site.settings.csp` — an owner-controlled allowlist of exact HTTPS origins: `scriptOrigins` union into `script-src`, `connectOrigins` union into `connect-src` together with `'self'` (the base plan has no `connect-src`, so creating one without `'self'` would cut same-origin fetches off from their `default-src` fallback). Entries are validated by `isCspOrigin` (`https://host[:port]`, optional `*.` label; never a path, `http://`, or a CSP keyword) and `parseSiteSettings` drops invalid entries one by one instead of failing the settings object. The plugin relaxation pass now unions `'self'` / `'unsafe-inline'` into `script-src` and `style-src` instead of replacing the directive, so a plugin tracker no longer strips the importmap sha256 hash or the site allowlist that the publisher already emitted. The write policy classifies `settings.csp` as structural. Editing surface: Settings → Publishing → Content Security Policy, two textareas (one origin per line, commit on blur, invalid lines reported field-locally and never persisted). Co-Authored-By: Claude Fable 5.1 --- docs/editor.md | 2 +- docs/features/plugin-system.md | 2 +- docs/features/publisher.md | 3 + docs/features/site-shell.md | 3 + server/publish/frontendInjections.ts | 13 +- server/writePolicy/siteDiff.ts | 3 +- src/__tests__/page-tree/siteSettings.test.ts | 117 ++++++++++++++++++ src/__tests__/publisher/cspPlan.test.ts | 69 +++++++++++ .../publisher/frontendInjections.test.ts | 20 +++ .../settings/settingsSections.test.tsx | 56 +++++++++ .../modals/Settings/SettingsModal.module.css | 18 +++ .../Settings/sections/PublishingSection.tsx | 94 +++++++++++++- src/core/page-tree/index.ts | 11 +- src/core/page-tree/siteSettings.ts | 77 ++++++++++++ src/core/publisher/render.ts | 25 +++- 15 files changed, 502 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/page-tree/siteSettings.test.ts diff --git a/docs/editor.md b/docs/editor.md index 2de45ee41..dea3d6183 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -609,7 +609,7 @@ The sidebar shell expands/collapses by animating `--*-panel-width`. The panel sl |---------------|------------------------------------------------------------------------------| | General | Site name, meta title, meta description, language, favicon | | Shortcuts | Auto-rendered keyboard shortcut reference from the keybindings registry | -| Publishing | Self-hosted runtime info + framework CSS tree-shaking toggle | +| Publishing | Self-hosted runtime info, framework CSS tree-shaking toggle, published-page CSP allowlist (`settings.csp`) | | Preferences | Catalog-driven editor preferences (auto-rendered from `PREFERENCE_CATALOG`) | Site-specific controls that were previously sections of this modal (Pages roster, Breakpoints/Viewports, Conditions) now live in their dedicated surfaces: the Site Explorer panel and `CanvasContextSelector` (unified condition axis). diff --git a/docs/features/plugin-system.md b/docs/features/plugin-system.md index 62f161280..09fc75547 100644 --- a/docs/features/plugin-system.md +++ b/docs/features/plugin-system.md @@ -624,7 +624,7 @@ Supported `kind` values are `script`, `script-inline`, `style`, `style-inline`, `attrs` passes through to the emitted tag except where `server/publish/frontendInjections.ts` owns the value: `data-plugin-id` on every tag, `src` on every script, strategy attributes on external scripts, and `href` plus `rel` on stylesheet assets. Bare `link` and `meta` declarations rely entirely on `attrs`. Inline JSON-LD uses `{ "kind": "script-inline", "attrs": { "type": "application/ld+json" }, "content": "..." }`. -The injection pipeline derives CSP changes from the plan. Inline scripts/styles add the matching `'unsafe-inline'` directive. `networkAllowedHosts[]` contributes published-page `connect-src` origins for plugins with frontend assets, which is why frontend trackers that call their own or third-party ingest endpoints must list those hosts as well as declare `frontend.assets`. +The injection pipeline derives CSP changes from the plan. Inline scripts/styles add the matching `'unsafe-inline'` directive. `networkAllowedHosts[]` contributes published-page `connect-src` origins for plugins with frontend assets, which is why frontend trackers that call their own or third-party ingest endpoints must list those hosts as well as declare `frontend.assets`. No manifest field adds a host to `script-src`: a plugin cannot load a remote third-party script. That is a site-owner decision, made in Settings → Publishing → Content Security Policy (`site.settings.csp`, see [publisher.md](publisher.md) → "CSP"). ### Settings — declared in `instatic-plugin.config.ts` / `plugin.json` diff --git a/docs/features/publisher.md b/docs/features/publisher.md index f7efd7b6a..f96769b11 100644 --- a/docs/features/publisher.md +++ b/docs/features/publisher.md @@ -364,6 +364,9 @@ The CSP is modelled as **data**, not a string assembled with regex. `src/core/pu - `media-src` deliberately mirrors `img-src`. Both govern passive references that execute nothing, so allowing a remote image while blocking a remote `