Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion docs/features/plugin-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
3 changes: 3 additions & 0 deletions docs/features/publisher.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<video>` would be an arbitrary line. It has to be stated explicitly: an unset `media-src` falls back to `default-src 'self'`, and the only symptom is a video that silently never loads.
- The server injection pipeline (`server/publish/frontendInjections.ts`) merges plugin `frontend.assets[]` relaxations + elected media-adapter origins into the plan in **one** pass via `rewriteCspMeta` — no second regex pass, no per-directive `RegExp`.
- The module-JS injector (`injectModuleScripts` in `server/publish/moduleJsBundle.ts`) merges `script-src 'self'` through the same `rewriteCspMeta` helper — only when at least one `/_instatic/module-js/<moduleId>.js` script tag was injected.
- **Site allowlist — `site.settings.csp`** (`SiteCspSettings` in `src/core/page-tree/siteSettings.ts`). The only sanctioned way to let a *third-party* script origin through. `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 for forms, holes and loops off from their `default-src` fallback). Entries are exact HTTPS host sources — `https://host[:port]`, optionally `https://*.host` — validated by `isCspOrigin`; `parseSiteSettings` drops anything else entry-by-entry. Edited in Settings → Publishing → Content Security Policy. Typical use: a runtime script that injects `https://www.googletagmanager.com/gtag/js` needs that origin in `scriptOrigins` and `https://*.google-analytics.com` in `connectOrigins`; the Meta pixel needs `https://connect.facebook.net` in `scriptOrigins` (its beacons are `<img>` requests, already covered by `img-src https:`).

Every downstream stage **unions** into the plan (`addCspSources`); only directives a stage fully owns (`worker-src`) are replaced. A plugin tracker's `script-src 'self'` relaxation therefore keeps the importmap `sha256` hash and the site allowlist next to it.

Because `serializeCsp` sorts, the same plugins + adapters always emit a **byte-identical** policy across runs (gated by `src/__tests__/publisher/cspPlan.test.ts`) — important for content-hashing and stable tests. Editing the emitted CSP string manually is **not** safe — it's a derived value. Mutate the plan (`setCspDirective` to replace, `addCspSources` to union) and re-serialize.

Expand Down
3 changes: 3 additions & 0 deletions docs/features/site-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,13 @@ type SiteSettings = {
language?: string
framework?: FrameworkSettings // colors, typography, spacing, preferences — absent when disabled
fonts?: SiteFontsSettings // installed font library + editable font tokens
csp?: SiteCspSettings // third-party origins allowed by the published-page CSP
shortcuts: Record<string, string> // keyboard shortcut overrides
}
```

`csp` is the published-page Content-Security-Policy allowlist: `scriptOrigins` (unioned into `script-src`) and `connectOrigins` (unioned into `connect-src`), each a list of exact `https://` host sources (a leading `*.` wildcard label is allowed; paths, `http://` and CSP keywords are not — `isCspOrigin` is the gate, `parseCspOriginList` the normalizer). Absent when empty; the write policy treats it as a structural setting. How the publisher applies it: [docs/features/publisher.md](publisher.md) → "CSP".

`framework` holds the structured design token system (`src/core/framework/`). When present it carries:
- `colors.tokens` — `FrameworkColorToken[]`, each with a slug (becomes a CSS var like `--primary`), light/dark values, utility generation flags (text/background/border/fill), shade/tint variant counts. Slugs are normalized by `normalizeFrameworkColorSlug` (trim, lowercase, strip leading `--`, replace non-alphanumeric runs with `-`). When two tokens normalize to the same root slug, the second receives a `-2` suffix, the third `-3`, and so on — resolved in generation order via `buildColorSlugMap` so the earlier token keeps the base name.
- `typography` — `FrameworkTypographySettings` with fluid scale groups, each emitting `font-size` vars + optional utility classes.
Expand Down
13 changes: 10 additions & 3 deletions server/publish/frontendInjections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
* - inline style → `style-src` gets `'unsafe-inline'`
* - external script/style: no relaxation needed (same-origin / allowlisted)
* - plugin-declared `networkAllowedHosts` → appended to `connect-src`
* Pure-meta plans get no CSP changes.
* Pure-meta plans get no CSP changes. Every relaxation is a union into
* the publisher's plan, so sources it already emitted (importmap hash,
* the site's `settings.csp` allowlist) survive.
*
* Pure data assembly — no DOM, no fetch. Called from a single place at the
* dispatcher (`server/router.ts → tryServePublishedPage / tryServeContentRoute`),
Expand Down Expand Up @@ -383,8 +385,13 @@ function relaxCspForPlan(html: string, plan: FrontendInjections, hasTags: boolea
// adds `'unsafe-inline'` on top
// • Worker spawn from any plugin script → relax `worker-src`
// to `'self' blob:`
//
// These are UNIONS, not replacements: the publisher may already have
// put the importmap `sha256-…` hash and the site's own allowlisted
// third-party origins (`settings.csp.scriptOrigins`) into `script-src`,
// and a plugin shipping a tracker must not silently strip them.
if (plan.hasExternalScript || plan.hasInlineScript) {
setCspDirective(
addCspSources(
csp,
'script-src',
plan.hasInlineScript ? ["'self'", "'unsafe-inline'"] : ["'self'"],
Expand All @@ -394,7 +401,7 @@ function relaxCspForPlan(html: string, plan: FrontendInjections, hasTags: boolea

// Style: relax to `'unsafe-inline'` only when an inline style is present.
if (plan.hasInlineStyle) {
setCspDirective(csp, 'style-src', ["'self'", "'unsafe-inline'"])
addCspSources(csp, 'style-src', ["'self'", "'unsafe-inline'"])
}

// Connect: union per-plugin `networkAllowedHosts`, plus the standard
Expand Down
3 changes: 2 additions & 1 deletion server/writePolicy/siteDiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export function validateSiteWriteDiff(
}

// Settings — split into chromatic-style fields (framework/fonts) and
// structural fields (metaTitle/metaDescription/favicon/language/shortcuts).
// structural fields (metaTitle/metaDescription/favicon/language/shortcuts/csp).
diffSettings(ctx, previous.settings, next.settings)

// breakpoints — adding / removing / reordering is style infra.
Expand Down Expand Up @@ -180,6 +180,7 @@ function diffSettings(
'faviconUrl',
'language',
'shortcuts',
'csp',
]
for (const key of structuralKeys) {
if (!deepEqual(prev[key], next[key])) {
Expand Down
117 changes: 117 additions & 0 deletions src/__tests__/page-tree/siteSettings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* SiteSettings — `settings.csp` allowlist parsing and origin validation.
*
* The allowlist is the only place an owner can let a third-party script
* origin through the publisher's `script-src 'self'`, so what it accepts
* matters: exact HTTPS origins (optionally a `*.` wildcard label), never a
* scheme wildcard, a path, or a CSP keyword. Persisted junk is dropped
* entry-by-entry rather than failing the whole settings object.
*/
import { describe, it, expect } from 'bun:test'
import { compiledCheck } from '@core/utils/typeboxCompiler'
import {
SiteCspSettingsSchema,
isCspOrigin,
parseCspOriginList,
parseSiteSettings,
} from '@core/page-tree'

describe('isCspOrigin', () => {
it.each([
'https://www.googletagmanager.com',
'https://connect.facebook.net',
'https://*.google-analytics.com',
'https://cdn.example.co.uk:8443',
'https://a-b.example',
])('accepts %s', (origin) => {
expect(isCspOrigin(origin)).toBe(true)
})

it.each([
'http://www.googletagmanager.com',
'https://www.googletagmanager.com/gtag/js',
'https://www.googletagmanager.com/',
'https://',
'https://localhost',
'https:',
"'unsafe-inline'",
'*',
'www.googletagmanager.com',
'https://*',
'https://-bad.example',
'https://user:pw@example.com',
'https://example.com?x=1',
' https://example.com',
])('rejects %s', (origin) => {
expect(isCspOrigin(origin)).toBe(false)
})
})

describe('parseCspOriginList', () => {
it('trims, drops blanks and invalid entries, and de-duplicates in first-seen order', () => {
expect(
parseCspOriginList([
' https://b.example ',
'',
'http://nope.example',
'https://a.example',
'https://b.example',
42,
null,
]),
).toEqual(['https://b.example', 'https://a.example'])
})

it('returns [] for non-arrays', () => {
expect(parseCspOriginList(undefined)).toEqual([])
expect(parseCspOriginList('https://a.example')).toEqual([])
expect(parseCspOriginList({ 0: 'https://a.example' })).toEqual([])
})
})

describe('parseSiteSettings — csp', () => {
it('keeps a valid allowlist', () => {
const settings = parseSiteSettings({
shortcuts: {},
csp: {
scriptOrigins: ['https://www.googletagmanager.com'],
connectOrigins: ['https://www.google-analytics.com'],
},
})
expect(settings.csp).toEqual({
scriptOrigins: ['https://www.googletagmanager.com'],
connectOrigins: ['https://www.google-analytics.com'],
})
expect(compiledCheck(SiteCspSettingsSchema, settings.csp)).toBe(true)
})

it('drops invalid entries without failing the rest of the settings', () => {
const settings = parseSiteSettings({
metaTitle: 'Kept',
shortcuts: {},
csp: {
scriptOrigins: ['https://ok.example', "'unsafe-inline'", 'javascript:alert(1)'],
connectOrigins: 'https://not-an-array.example',
},
})
expect(settings.metaTitle).toBe('Kept')
expect(settings.csp).toEqual({ scriptOrigins: ['https://ok.example'], connectOrigins: [] })
})

it('omits csp entirely when nothing valid remains or the field is malformed', () => {
expect(parseSiteSettings({ shortcuts: {}, csp: { scriptOrigins: [], connectOrigins: [] } }).csp)
.toBeUndefined()
expect(parseSiteSettings({ shortcuts: {}, csp: ['https://a.example'] }).csp).toBeUndefined()
expect(parseSiteSettings({ shortcuts: {} }).csp).toBeUndefined()
expect('csp' in parseSiteSettings({ shortcuts: {} })).toBe(false)
})

it('schema rejects a raw list carrying a non-origin', () => {
expect(
compiledCheck(SiteCspSettingsSchema, {
scriptOrigins: ['https://ok.example', 'http://nope.example'],
connectOrigins: [],
}),
).toBe(false)
})
})
69 changes: 69 additions & 0 deletions src/__tests__/publisher/cspPlan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from '../../../server/publish/frontendInjections'
import { VideoModule } from '@modules/base/video'
import { makeModule, makeRegistry, makePage, makeSite } from './helpers'
import { DEFAULT_SITE_SETTINGS } from '@core/page-tree'

describe('CspPlan — serialization is deterministic and sorted', () => {
it('sorts directives by name and sources within each directive', () => {
Expand Down Expand Up @@ -261,3 +262,71 @@ describe('publishPage — CSP frame-src from module cspSources', () => {
expect(csp).toContain('https://www.youtube-nocookie.com')
})
})

// ---------------------------------------------------------------------------
// publishPage — site-level CSP allowlist (`settings.csp`)
//
// The base policy locks `script-src` to `'self'`, which blocks every
// third-party tag (GA / GTM loaders, the Meta pixel). Plugins cannot lift it:
// `frontend.assets[]` is same-origin only and `networkAllowedHosts` reaches
// `connect-src`, not `script-src`. The owner's explicit allowlist is the one
// sanctioned way through, and it must survive the downstream plugin pass.
// ---------------------------------------------------------------------------

const ANALYTICS_CSP = {
scriptOrigins: ['https://www.googletagmanager.com', 'https://connect.facebook.net'],
connectOrigins: ['https://www.google-analytics.com', 'https://*.google-analytics.com'],
}

function siteWithCsp(csp: typeof ANALYTICS_CSP | undefined) {
return makeSite({
settings: { ...structuredClone(DEFAULT_SITE_SETTINGS), ...(csp ? { csp } : {}) },
})
}

describe('publishPage — CSP allowlist from settings.csp', () => {
const plainPage = () => makePage({ root: { moduleId: 'test.plain', props: {} } })
const reg = () => makeRegistry({ 'test.plain': makeModule('test.plain') })

it('unions allowlisted script origins into script-src', () => {
const { html } = publishPage(plainPage(), siteWithCsp(ANALYTICS_CSP), reg())
const csp = extractPublishedCsp(html)
expect(csp).toContain('script-src https://connect.facebook.net https://www.googletagmanager.com;')
expect(csp).not.toContain("script-src 'none'")
})

it("adds connect-src with 'self' so same-origin fetches keep working", () => {
const { html } = publishPage(plainPage(), siteWithCsp(ANALYTICS_CSP), reg())
const csp = extractPublishedCsp(html)
expect(csp).toContain(
"connect-src 'self' https://*.google-analytics.com https://www.google-analytics.com;",
)
})

it('leaves the policy untouched when no allowlist is set', () => {
const { html } = publishPage(plainPage(), siteWithCsp(undefined), reg())
const csp = extractPublishedCsp(html)
expect(csp).toContain("script-src 'none'")
expect(csp).not.toContain('connect-src')
})

it('survives the plugin relaxation pass (plugin tracker must not strip the allowlist)', () => {
const { html } = publishPage(plainPage(), siteWithCsp(ANALYTICS_CSP), reg())
const out = injectFrontendAssets(
html,
planWith({
hasExternalScript: true,
tags: {
head: [],
'head-end': [],
'body-start': [],
'body-end': ['<script src="/uploads/plugins/acme/1.0.0/t.js" defer></script>'],
},
}),
)
const csp = extractPublishedCsp(out)
expect(csp).toContain(
"script-src 'self' https://connect.facebook.net https://www.googletagmanager.com;",
)
})
})
20 changes: 20 additions & 0 deletions src/__tests__/publisher/frontendInjections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ import {
} from '../../../server/publish/frontendInjections'
import { createFakeDb } from '../server/dbTestFake'

const PAGE_WITH_ALLOWLISTED_CSP = `<!doctype html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'sha256-IMPORTMAP' https://www.googletagmanager.com; worker-src 'none'; style-src 'self' 'unsafe-inline';">
</head>
<body></body>
</html>`

const PAGE_WITH_CSP_META = `<!doctype html>
<html>
<head>
Expand Down Expand Up @@ -147,6 +155,18 @@ describe('frontend injection — asset attributes', () => {
})

describe('frontend injection — CSP relaxation', () => {
it('unions `self` into script-src without dropping the importmap hash or site allowlist', () => {
const out = injectFrontendAssets(PAGE_WITH_ALLOWLISTED_CSP, {
...emptyPlan(),
hasExternalScript: true,
hasInlineScript: true,
tags: { ...emptyPlan().tags, 'body-end': ['<script>window.t=1</script>'] },
})
expect(out).toContain(
"script-src 'self' 'sha256-IMPORTMAP' 'unsafe-inline' https://www.googletagmanager.com;",
)
})

it('keeps script-src `none` when no plugin contributes a tag', () => {
const out = injectFrontendAssets(PAGE_WITH_CSP_META, emptyPlan())
expect(out).toContain("script-src 'none'")
Expand Down
Loading