Skip to content
Open
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
3 changes: 2 additions & 1 deletion docs/features/publisher.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,8 @@ Plugins inject at four anchors. The order matters — see [docs/features/plugin-

The CSP is modelled as **data**, not a string assembled with regex. `src/core/publisher/cspPlan.ts` owns one `CspPlan` (`Map<directive, Set<source>>`) and the deterministic `serializeCsp` (directives sorted by name, sources sorted within each directive). Every stage contributes to the same plan:

- `createBaseCspPlan` (in `render.ts`) emits the base policy: `default-src 'self'`, restricted `script-src` (`'none'` → `'self'` + importmap `sha256` once any script tag is present), `style-src 'self' 'unsafe-inline'`, `img-src 'self' data: https:`, `frame-src 'none'`, and `worker-src` (`'none'` → `'self' blob:`).
- `createBaseCspPlan` (in `render.ts`) emits the base policy: `default-src 'self'`, restricted `script-src` (`'none'` → `'self'` + importmap `sha256` once any script tag is present), `style-src 'self' 'unsafe-inline'`, `img-src 'self' data: https:`, `media-src 'self' data: https:`, `frame-src 'none'`, and `worker-src` (`'none'` → `'self' blob:`).
- `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.

Expand Down
17 changes: 15 additions & 2 deletions src/__tests__/publisher/cspPlan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,27 @@ describe('CspPlan — serialization is deterministic and sorted', () => {
it('sorts directives by name and sources within each directive', () => {
const plan = createBaseCspPlan({ anyScriptTag: false })
const csp = serializeCsp(plan)
// Directives alphabetical: default-src < frame-src < img-src < script-src
// < style-src < worker-src
// Directives alphabetical: default-src < frame-src < img-src < media-src
// < script-src < style-src < worker-src
expect(csp).toBe(
"default-src 'self'; frame-src 'none'; img-src 'self' data: https:; " +
"media-src 'self' data: https:; " +
"script-src 'none'; style-src 'self' 'unsafe-inline'; worker-src 'none';",
)
})

it('lets a cross-origin video load, exactly like a cross-origin image', () => {
// Without an explicit `media-src` the browser falls back to
// `default-src 'self'` and blocks every remote `<video>`/`<audio>` — a
// page could show a remote image but never play a remote video. The
// element and URL are both correct, so the only symptom is silence.
const csp = serializeCsp(createBaseCspPlan({ anyScriptTag: true }))
expect(csp).toContain("media-src 'self' data: https:;")
const img = /img-src ([^;]+);/.exec(csp)?.[1]
const media = /media-src ([^;]+);/.exec(csp)?.[1]
expect(media).toBe(img)
})

it('produces a byte-identical policy regardless of source insertion order', () => {
const a = createBaseCspPlan({ anyScriptTag: true, importmapSha: 'ABC123' })
const b = createBaseCspPlan({ anyScriptTag: true, importmapSha: 'ABC123' })
Expand Down
8 changes: 8 additions & 0 deletions src/core/publisher/cspPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ export function createBaseCspPlan(opts: {

setCspDirective(plan, 'style-src', ["'self'", "'unsafe-inline'"])
setCspDirective(plan, 'img-src', ["'self'", 'data:', 'https:'])
// Same sources as `img-src`, and for the same reason. Without an explicit
// `media-src` the browser falls back to `default-src 'self'` and refuses
// every cross-origin `<video>` / `<audio>` — so a page could show a remote
// image but not play a remote video, which is an arbitrary line to draw
// between two passive, non-executing references. The failure is also
// invisible in the markup: the element is correct, the URL resolves, and
// only the console says why nothing happens.
setCspDirective(plan, 'media-src', ["'self'", 'data:', 'https:'])
setCspDirective(plan, 'frame-src', ["'none'"])
setCspDirective(plan, 'worker-src', opts.anyScriptTag ? ["'self'", 'blob:'] : ["'none'"])
return plan
Expand Down