Skip to content

No Content-Security-Policy: the app has no second line of defence behind the Markdown sanitiser #77

Description

@usekaneo

From the CON-43 security audit (2026-09-14). Severity medium, reachable in the shipped app.

index.html carries no <meta http-equiv="Content-Security-Policy">, and nothing in vite.config.ts, docker/ or scripts/ sets the header at serve time. A grep for Content-Security-Policy over the whole repository returns nothing.

Why it matters here

The audit found the Markdown pipeline itself clean — rehype-sanitize runs before the app's own rehype plugins, keepMentionUrls falls through to react-markdown's defaultUrlTransform for every scheme that is not a decodable nostr: mention, no dangerouslySetInnerHTML anywhere in src/, and Shiki is asked for tokens rather than HTML. So this is not a hole today.

It is the absence of a second layer. The entire XSS defence rests on that one pipeline staying correct, while the content it renders is written by arbitrary, mutually untrusted npubs, and the pipeline is built out of npm packages (react-markdown, rehype-sanitize, remark-gfm, shiki) that render that content. A CSP is what would still contain a future regression in our own sanitising, or a compromised release of one of those packages. That is exactly the case CSP exists for.

The detail that makes this less trivial than it looks

index.html contains an inline <script> — the theme bootstrap that must run before the bundle so light mode does not flash on every reload (docs/12-theming.md). A plain script-src 'self' breaks it. So the ticket has to pick one of:

  • move the bootstrap into its own file and accept the extra request before first paint (it is tiny, but it is render-blocking by design),
  • give it a hash ('sha256-…') and keep the hash in sync with the script — a footgun, because editing the script silently breaks the theme,
  • or a nonce, which needs a server that can generate one per response and therefore rules out a purely static host.

Decide this deliberately; do not pick whichever makes the error go away.

Directives the policy needs

  • script-src 'self' plus whatever the decision above requires. Not 'unsafe-inline'.
  • object-src 'none', base-uri 'none', frame-ancestors 'none'.
  • connect-src — the relay is a wss:// origin, and src/nostr/relay-status.ts:19 also does an https:// NIP-11 fetch against the same host. Note that the relay host is not fixed: it comes out of the space address in the URL (see the sibling ticket on parseGroupAddress), so a narrow connect-src and arbitrary relay hosts are in direct tension. Resolve that tension explicitly rather than widening the directive by reflex.
  • img-src has to stay wide: loading images from arbitrary hosts is a deliberate decision (docs/09-security-privacy.md → "Images are loaded directly"). Say so in a comment next to the directive, so nobody later "fixes" it.

Acceptance

  • A CSP is actually delivered — verified in the browser's network tab against a production build, not just present in a config file.
  • The theme bootstrap still runs before first paint: no light flash on reload in dark mode.
  • The app works end to end under the policy: page render, code highlighting, image loading, relay connect, Blossom upload.
  • No 'unsafe-inline' and no 'unsafe-eval' in script-src.
  • The chosen approach and its cost are written down in docs/09-security-privacy.md.
  • npm run typecheck && npm run lint && npm test && npm run build pass.

Affected: index.html, vite.config.ts, docker/, docs/09-security-privacy.md, docs/12-theming.md

Source: CON-43, finding A1.


Task: wwr5yu38qczpy5f44w4dfmcx

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions