diff --git a/docs/09-security-privacy.md b/docs/09-security-privacy.md index 8aeffc8..3fea340 100644 --- a/docs/09-security-privacy.md +++ b/docs/09-security-privacy.md @@ -34,7 +34,7 @@ | Risk | Countermeasure | |---|---| -| XSS through Markdown from arbitrary npubs | `rehype-sanitize` with a strict allowlist, no `dangerouslySetInnerHTML`, no raw HTML, no `javascript:` links | +| XSS through Markdown from arbitrary npubs | `rehype-sanitize` with a strict allowlist, no `dangerouslySetInnerHTML`, no raw HTML, no `javascript:` links — and a Content-Security-Policy behind it as a second layer, see below | | Images/iframes used as trackers | **Accepted trade:** every image is loaded directly, whatever host it points at — so a host learns the reader's IP, which page is being read and when, and can count reads. No iframes. See "Images are loaded directly" below | | Forged `h` tags (an event from another group smuggled in) | Checked after loading: `h` must match the open space, otherwise the event is discarded | | Forgetting to verify signatures | Verification is enforced in the data layer, not optional per call | @@ -42,6 +42,200 @@ | Spam in open spaces | Relay rate limits + moderated deletion (`9005`) + a "members only" UI filter | | Key theft through the app | No handling of nsec at all. NIP-07/NIP-46 only | +## Content-Security-Policy + +The sanitising pipeline above is the first layer, and as of this writing it is +correct — the CSP closes no hole that is open today. It is the **second** layer, +and it exists for the day a regression in that pipeline, or a compromised npm +dependency somewhere in the tree, puts a script into the page anyway. The app +renders Markdown written by arbitrary public keys, so that day is worth +planning for. + +**This section describes a policy the app is *meant* to be served with. Today +nothing in production sends it — see "The risk" at the end before reading any +further.** + +The policy is defined once, in `src/csp.ts`, which `vite.config.ts` imports: + +``` +default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; +img-src * data:; connect-src 'self' https: wss:; font-src 'self'; +object-src 'none'; frame-src 'none'; worker-src 'none'; base-uri 'none'; +form-action 'none'; frame-ancestors 'none' +``` + +### A header, not a meta tag + +`` silently ignores +`frame-ancestors`, `report-uri`/`report-to` and `sandbox`; they are only +honoured on an HTTP response header. `frame-ancestors 'none'` is part of this +policy, so a meta tag could not deliver it. A meta tag would also break +`npm run dev` (see below) and leave two copies of the policy to keep in sync. + +`vite.config.ts` puts the header on `preview.headers`, so +`npm run build && npm run preview` serves the **production build** under the +policy. That makes it checkable on the artefact that actually ships, without +inventing a deployment — with one deliberate difference in `connect-src`, see +"Preview relaxes `connect-src`, and only that" below. + +### The directives that are not obvious + +- **`script-src 'self'`** — no `'unsafe-inline'`, no `'unsafe-eval'`. This is + the whole point of the exercise. It cost one change: the theme bootstrap that + used to sit inline in `index.html` now lives in `public/theme-bootstrap.js` + ([12](12-theming.md)). `src/` contains no `eval` and no `new Function`. +- **`style-src 'unsafe-inline'`** — forced, and deliberate. The **binding** + constraint is CodeMirror, not our own components: `@codemirror/view` mounts + its theme through `style-mod`'s `StyleModule.mount`, which for a document + root — every mount in this app, since nothing here uses shadow DOM — creates + a `