--- name: nostr-security description: Threat model and defenses for a web Nostr client — why XSS is catastrophic when nsec keys live in localStorage, CSP as defense-in-depth, URL and CSS sanitization for untrusted event data, and author filtering for trust-sensitive queries (admin actions, moderators, addressable events, NIP-72 communities). Load when building trust-boundary features, rendering user-controlled URLs or markup, interpolating event data into CSS, or reviewing the app's security posture. --- # Nostr Security ## Threat model **Nostr private keys (`nsec`) are stored in plaintext in `localStorage`.** Any JavaScript running on the origin can read them with `localStorage.getItem('nostr-login')`. A successful XSS = instant, silent, irreversible key theft — no rotation, no revocation, permanent impersonation across every Nostr client the user ever touches. External signers (NIP-07 extension, NIP-46 bunker) don't change this: an XSS can still ask the active signer to sign arbitrary events, drain funds via zaps, or scrape DMs as they decrypt. **Treat every piece of untrusted data as a script-injection vector** — event tags, `content`, metadata, URL params, relay responses. ## Defense-in-depth **Content Security Policy.** `index.html` ships a restrictive CSP: `default-src 'none'`, `script-src 'self'` (no inline scripts, no `eval`), `base-uri 'self'`, `connect-src 'self' https: wss:`. The one intentional gap is `style-src 'unsafe-inline'` — required by Tailwind/shadcn — which means **CSS injection is not blocked by CSP; sanitization is on you**. When modifying CSP, only narrow it. Never add `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, `http:`, or wildcard sources. **Never use `dangerouslySetInnerHTML`, `innerHTML`, `insertAdjacentHTML`, or `document.write`** with event data, URL params, or any other untrusted string. React's JSX auto-escapes interpolated strings — the moment you bypass that, CSP alone won't save you. If you must render HTML from event data, pipe it through a strict allowlist sanitizer (e.g. DOMPurify) at the parse layer. **Sanitize URLs and CSS values** — see §1 and §2. ## 1. URL sanitization Any URL from event tags, `content`, metadata fields (`picture`, `banner`, `website`, `nip05`, etc.), or relay hints is untrusted. Threats beyond `javascript:` XSS: `data:` resource exhaustion / phishing, `http://` IP leaks, relative paths triggering same-origin requests, malformed strings crashing downstream parsers. **Sanitize unconditionally at the parse layer.** mkstack doesn't ship a helper — add `src/lib/sanitizeUrl.ts` the first time you need one: ```ts /** Returns canonical href for valid https:// URLs, undefined otherwise. */ export function sanitizeUrl(value: string | undefined | null): string | undefined { if (!value) return undefined; try { const url = new URL(value); return url.protocol === 'https:' ? url.href : undefined; } catch { return undefined; } } ``` Apply it when extracting data from events, not at each render site: ```ts function parseProfile(event: NostrEvent): Profile { const meta = JSON.parse(event.content); return { name: meta.name, picture: sanitizeUrl(meta.picture), banner: sanitizeUrl(meta.banner), website: sanitizeUrl(meta.website), }; } ``` **When sanitization is NOT required:** URLs matched by a regex that constrains the protocol (e.g. a content tokenizer matching `https?://...` — the regex *is* the sanitizer), hardcoded/app-generated URLs, and strings rendered as plain text that never land in an attribute, CSS value, or network request. ## 2. CSS injection Event data interpolated into CSS (a `