Skip to content

feat(publisher): site-level CSP allowlist for third-party script origins - #513

Draft
mjennings3 wants to merge 1 commit into
CoreBunch:mainfrom
mjennings3:feat/csp-script-origins
Draft

feat(publisher): site-level CSP allowlist for third-party script origins#513
mjennings3 wants to merge 1 commit into
CoreBunch:mainfrom
mjennings3:feat/csp-script-origins

Conversation

@mjennings3

Copy link
Copy Markdown

What changed

Adds site.settings.csp, an owner-controlled allowlist of exact HTTPS origins that the publisher merges into the published-page Content-Security-Policy:

  • scriptOrigins → unioned into script-src
  • connectOrigins → unioned into connect-src together with 'self' (the base plan has no connect-src; creating one without 'self' would cut same-origin fetches for forms, holes and loops off from their default-src fallback)

Entries are validated by isCspOrigin: https://host[:port], optionally a leading *. wildcard label. Paths, http://, credentials and CSP keywords ('unsafe-inline', *) are rejected. parseSiteSettings drops invalid entries one at a time instead of failing the settings object, and an empty allowlist never persists.

Editing surface: Settings → Publishing → Content Security Policy, two textareas (one origin per line, commit on blur, invalid lines reported field-locally with role="alert" and never persisted). Uses the existing Textarea primitive and tokens only.

Two related fixes in the same path:

  • relaxCspForPlan (plugin frontend assets) now unions 'self' / 'unsafe-inline' into script-src and style-src instead of replacing the directive. Before, any plugin shipping a frontend script silently stripped the importmap sha256-… hash the publisher had emitted (and would have stripped this allowlist).
  • The write policy (siteDiff.ts) classifies settings.csp as a structural setting.

Why

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 ships with those tags blocked, with the only symptom being a console CSP violation. This is the gap described in #227.

A per-site allowlist keeps the intent of the strict default (visitor pages run no host-supplied JS unless the owner says so) while giving the owner one explicit, auditable place to say so. It deliberately does not add a manifest field for plugins: which third parties may run on a site is the owner's decision, not a plugin's.

Impact

  • Users: new Publishing settings block. No change to existing sites until an origin is entered.
  • Developers: SiteSettings.csp?: SiteCspSettings (optional, additive JSON shape, no migration). New barrel exports from @core/page-tree: SiteCspSettingsSchema, CSP_ORIGIN_PATTERN, isCspOrigin, parseCspOriginList, parseSiteSettings. Docs updated in publisher.md (CSP section), site-shell.md (SiteSettings), plugin-system.md, editor.md.

Verification

bun run build   # tsc -b && vite build — passes
bun run lint    # passes
bun test        # 6861 pass; 8 failures pre-exist on main (toolbar.test.ts ×7, store/selectorStability.test.ts ×1 — reproduced on a clean origin/main worktree)

New tests: page-tree/siteSettings.test.ts (origin validation, tolerant parse), publisher/cspPlan.test.ts (allowlist through publishPage, survives the plugin pass), publisher/frontendInjections.test.ts (union keeps importmap hash + allowlist), settings/settingsSections.test.tsx (UI commit / invalid-line reporting / clearing).

Closes #227.

🤖 Generated with Claude Code

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 (CoreBunch#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 <noreply@anthropic.com>
@7heMech

7heMech commented Sep 9, 2026

Copy link
Copy Markdown

Would be awesome. I can't embed fonts from adobe or submit my forms to self-hosted n8n because of this issue.

@7heMech

7heMech commented Sep 9, 2026

Copy link
Copy Markdown

Also sould we please expand site.settings.csp to also support styleOrigins and fontOrigins?

Third-party hosted fonts like Adobe Fonts (Typekit) cannot be self-hosted due to encrypted/dynamic font streaming and licensing terms. To support them without violating CSP, we need:

  1. In SiteCspSettingsSchema:

    • styleOrigins?: Type.Array(CspOriginSchema)
    • fontOrigins?: Type.Array(CspOriginSchema)
  2. In render.ts (buildContentSecurityPolicy):

    • addCspSources(plan, 'style-src', siteCsp.styleOrigins)
    • addCspSources(plan, 'font-src', ["'self'", ...siteCsp.fontOrigins])

This would allow configuring:

  • styleOrigins: https://use.typekit.net, https://p.typekit.net
  • fontOrigins: https://use.typekit.net

@7heMech

7heMech commented Sep 9, 2026

Copy link
Copy Markdown

Hey, I don't require this anymore, I wrote a plugin to serve the fonts locally from the domain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: GTM possibility

2 participants