Skip to content

CON-44: a Content-Security-Policy behind the Markdown sanitiser - #89

Merged
molgerz merged 1 commit into
mainfrom
con-44-content-security-policy
Sep 19, 2026
Merged

molgerz merged 1 commit into
mainfrom
con-44-content-security-policy

Conversation

@molgerz

@molgerz molgerz commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

Closes CON-44. From the CON-43 security audit.

The app renders Markdown written by arbitrary public keys and the sanitiser is the only thing between that and script execution. This adds the second layer.

What is in it

  • One policy, in src/csp.ts, pinned by src/csp.test.ts. It lives outside vite.config.ts so the invariants can be asserted without booting the config's plugins, and nothing under src/ imports it, so it never reaches the bundle.
  • Delivered as an HTTP response header, never as <meta http-equiv> — which ignores frame-ancestors, and frame-ancestors 'none' is part of this policy.
  • script-src 'self', with no 'unsafe-inline' and no 'unsafe-eval'. The theme bootstrap moved out of index.html into public/theme-bootstrap.js for it; it stays classic and render-blocking, or a reload flashes light mode.
  • style-src keeps 'unsafe-inline', and the binding constraint is CodeMirror, not React: @codemirror/view mounts its theme through style-mod, which assigns textContent to a <style> element it creates. No refactor of our own code removes that — only a per-response nonce, which a static host cannot mint. The five computed React style attributes would be removable; an inline style cannot execute script.
  • connect-src 'self' https: wss: is scheme-scoped on purpose: the space relay comes from the link, so no build-time host allowlist can exist without breaking every shared space address (this follows from CON-46).
  • npm run preview serves one deliberate variation of the policy. The shipped connect-src blocks the local relay (ws://localhost:<port> and its http:// NIP-11 fetch) and the local Blossom server, so a preview run under it never loads a space — it can only show that a header arrived, which is not what preview.headers is for. PREVIEW_CSP_DIRECTIVES adds loopback to connect-src and changes nothing else; script-src and every other directive are served exactly as they ship. The cost is that connect-src is the one directive a preview run no longer checks as shipped — the cheapest to give up, since it can never be narrow in production either. Both policies are derived from one set of directives, and the tests fail if they differ anywhere but connect-src, or if the shipped one grows ws:/http:/a loopback host.
  • img-src * data: stays open by the documented decision that pages may embed images from any host.
  • npm run preview is the only thing in this repo that sends the header, and it sends it on the real build.

How to test

  1. ./scripts/dev-relay-up.sh, then npm run build && npm run preview. Check the Content-Security-Policy response header on the document: it is the shipped policy plus the four loopback sources in connect-src.
  2. Open a space. The relay connects and the NIP-11 fetch returns 200 — under the shipped connect-src both are blocked, which is the reason for the variation. Load the page with a cache-busting query the first time: Vite answers a revalidation with 304, and a 304 does not carry the header, so a cached document keeps the previous policy and will mislead you.
  3. DevTools console: no CSP violations while navigating, editing a page (CodeMirror), viewing highlighted code, and switching theme. Signing in needs a real NIP-07 extension — the dev signer is gated on import.meta.env.DEV and does not exist in a production build.
  4. Reload in dark mode: no flash of light mode (the moved bootstrap).
  5. npm run dev still works and deliberately sends no policy.
  6. src/csp.test.ts pins the invariants — script-src free of 'unsafe-inline'/'unsafe-eval', frame-ancestors 'none', the shipped connect-src free of plaintext schemes, and the preview policy differing from the shipped one in connect-src and nowhere else.

Known gap

Nothing in production delivers this header yet: the repo carries no host configuration at all. That is filed as CON-48 and is not in this PR.

The app renders Markdown written by arbitrary public keys, and the sanitiser is
the only thing standing between that and script execution. This adds the second
layer: one policy in `src/csp.ts`, pinned by `src/csp.test.ts`, delivered as an
HTTP response header — never as `<meta http-equiv>`, which ignores
`frame-ancestors`, the directive this policy needs.

`script-src 'self'` with no `'unsafe-inline'` and no `'unsafe-eval'` is the
directive that matters, so the theme bootstrap moved out of `index.html` into
`public/theme-bootstrap.js`; it stays classic and render-blocking, or a reload
flashes light mode. `style-src` keeps `'unsafe-inline'` because CodeMirror
mounts its theme through style-mod, which assigns `textContent` to a `<style>`
element it creates — no refactor of our own code removes that, only a
per-response nonce a static host cannot mint. `connect-src` stays
scheme-scoped: the space relay comes from the link, so a build-time host
allowlist would break every shared space address.

`vite preview` is the only thing in this repo that sends the header, and it
sends it on the real build, so the policy can be checked on the artefact that
ships — with one deliberate difference. The shipped `connect-src` permits
`https:` and `wss:` only, which blocks the local relay (`ws://localhost:<port>`
plus its `http://` NIP-11 fetch) and the local Blossom server, so a preview run
under it never loads a space and proves nothing beyond the header arriving.
`PREVIEW_CSP_DIRECTIVES` therefore adds loopback to `connect-src`, and nothing
else: `script-src` and the rest are served exactly as they ship. The cost is
that `connect-src` is the one directive a preview run no longer checks as
shipped — the cheapest one to give up, since the relay host comes out of the
link at runtime (CON-46) and it can never be narrow in production either.
Both policies are derived from one set of directives, and the tests fail if
they ever differ anywhere but `connect-src`, or if the shipped one grows a
plaintext scheme.

The dev server deliberately sends none: the React plugin injects its own
inline preamble, so a dev policy would need the very `'unsafe-inline'` this
ticket is about. Nothing in production delivers the header yet — there is no
host configuration in this repo at all — which is CON-48.
@molgerz
molgerz force-pushed the con-44-content-security-policy branch from 1be37ac to 5661694 Compare September 18, 2026 23:57
@molgerz
molgerz merged commit 80a385e into main Sep 19, 2026
1 check passed
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.

1 participant