CON-44: a Content-Security-Policy behind the Markdown sanitiser - #89
Merged
Merged
Conversation
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
force-pushed
the
con-44-content-security-policy
branch
from
September 18, 2026 23:57
1be37ac to
5661694
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
src/csp.ts, pinned bysrc/csp.test.ts. It lives outsidevite.config.tsso the invariants can be asserted without booting the config's plugins, and nothing undersrc/imports it, so it never reaches the bundle.<meta http-equiv>— which ignoresframe-ancestors, andframe-ancestors 'none'is part of this policy.script-src 'self', with no'unsafe-inline'and no'unsafe-eval'. The theme bootstrap moved out ofindex.htmlintopublic/theme-bootstrap.jsfor it; it stays classic and render-blocking, or a reload flashes light mode.style-srckeeps'unsafe-inline', and the binding constraint is CodeMirror, not React:@codemirror/viewmounts its theme through style-mod, which assignstextContentto 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 Reactstyleattributes 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 previewserves one deliberate variation of the policy. The shippedconnect-srcblocks the local relay (ws://localhost:<port>and itshttp://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 whatpreview.headersis for.PREVIEW_CSP_DIRECTIVESadds loopback toconnect-srcand changes nothing else;script-srcand every other directive are served exactly as they ship. The cost is thatconnect-srcis 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 butconnect-src, or if the shipped one growsws:/http:/a loopback host.img-src * data:stays open by the documented decision that pages may embed images from any host.npm run previewis the only thing in this repo that sends the header, and it sends it on the real build.How to test
./scripts/dev-relay-up.sh, thennpm run build && npm run preview. Check theContent-Security-Policyresponse header on the document: it is the shipped policy plus the four loopback sources inconnect-src.connect-srcboth are blocked, which is the reason for the variation. Load the page with a cache-busting query the first time: Vite answers a revalidation with304, and a304does not carry the header, so a cached document keeps the previous policy and will mislead you.import.meta.env.DEVand does not exist in a production build.npm run devstill works and deliberately sends no policy.src/csp.test.tspins the invariants —script-srcfree of'unsafe-inline'/'unsafe-eval',frame-ancestors 'none', the shippedconnect-srcfree of plaintext schemes, and the preview policy differing from the shipped one inconnect-srcand 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.