# Security Policy SkateSpots is a **client-side, decentralized Nostr web application**. There is no central backend that holds user data — the app runs entirely in the browser and talks directly to Nostr relays, Blossom media servers, Lightning wallets, and a few public read-only APIs. This document is written for security researchers and auditors reviewing the codebase. - **Live app:** https://skate-spots.org - **Source (Nostr git):** `nostr://npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx/git.shakespeare.diy/SkateSpots` - **Stack:** React 18 + Vite + TypeScript, TailwindCSS, shadcn/ui, Nostrify, TanStack Query, Leaflet. --- ## Reporting a Vulnerability Please report privately first — **do not** open a public issue or publish a PoC before we've had a chance to respond. - **Nostr DM (preferred):** `npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx` - **NIP-05:** `skatespots@goskatespots.nostr1.com` Please include: 1. A clear description of the issue and its impact. 2. Reproduction steps (a minimal PoC event/payload is ideal). 3. The affected file(s) / route(s) and, if known, a suggested fix. We aim to acknowledge reports within a few days. Because this is a community-run, non-commercial project there is **no paid bug bounty**, but credit is gladly given to reporters who want it. ### In scope - The web app served from `https://skate-spots.org` and this repository's source. - XSS / script injection, CSP bypasses, unsafe handling of untrusted event data, URL/CSS injection, clickjacking, and logic flaws that leak or misuse user secrets (Nostr keys, wallet credentials). ### Out of scope - **Plaintext storage of `nsec` / wallet credentials in `localStorage`.** See [Threat model](#threat-model) — this is inherent to client-side Nostr signing and Lightning payments, not a defect unique to this app. Reports about *the existence* of these values in storage will be closed as "by design"; reports about a way to *reach* them (e.g. an XSS) are very much in scope. - Vulnerabilities in third-party relays, Blossom servers, Cashu mints, wallets, browser extensions, or signer apps the user chooses to connect. - Denial of service against public relays/APIs, spam, or rate-limit abuse. - Social engineering, phishing of end users, or physical/device compromise. - The native Capacitor (iOS/Android) wrappers' OS-level sandboxing. --- ## Threat model **The most dangerous class of bug in this app is XSS**, because a Nostr web client necessarily handles secrets in the browser: - **Nostr private key (`nsec`).** For accounts created in-app or logged in by pasting an nsec, Nostrify stores the key inside the login object in `localStorage` (`nostr:login`). Logging in with a **browser extension (NIP-07)** or a **remote signer / bunker (NIP-46)** keeps the key *outside* the page and is the recommended high-security path. - **Lightning wallet credentials.** NWC connection strings (`nostr+walletconnect://…`) are stored in `localStorage` under `nwc-connections`. The embedded secret authorizes `pay_invoice`. - **Cashu ecash (native build only).** The mobile (Capacitor) build enables a built-in Cashu wallet that the web build disables (`isNativePlatform()` gate in `src/hooks/wallet/useCashu.ts`). Its bearer **proofs** live in IndexedDB (database `cashu-wallet`) and its **seed mnemonic** in `localStorage` (`cashu-mnemonic`). Ecash proofs are bearer instruments — whoever reads them can spend them. An injected script on the app origin can drain them directly, in addition to the nsec/NWC theft above. **This is not "web only."** The mobile app is the same bundle wrapped in Capacitor and is exposed to the identical map/relay injection surface. A **Nostr key cannot be rotated in place** — an `nsec` compromise is permanent and follows the user across every Nostr client they use. Therefore any code path that can execute attacker-controlled script on our origin is treated as **critical**. Defense is layered: 1. **Never inject untrusted data as markup.** No `dangerouslySetInnerHTML`, `innerHTML`, `insertAdjacentHTML`, or `document.write` with event/API/URL data. React's JSX auto-escaping is the default; where we build DOM manually (e.g. Leaflet popups) we use `document.createElement` + `textContent`. 2. **Content Security Policy** as defense-in-depth (see below). 3. **URL sanitization** for any event/API URL before it reaches an `href` / `src` / CSS `url()`. 4. **Budget guidance** to cap the blast radius of the one credential the app must actively hold (NWC). ### Trust boundaries Everything below is **untrusted** and must be treated as attacker-controlled: - All Nostr event content and tags (any pubkey can publish any event to a relay we read from — spots, tours, crews, events, profiles, notes, reactions, zaps). - Responses from third-party APIs (e.g. BTC Map places, weather, geocoding). - URL parameters and NIP-19 identifiers in the address bar. - Relay responses in general — relays are dumb pipes; a matching event is not a trusted event. --- ## Defenses in place ### Content Security Policy The CSP applies to **both the web build and the native (Capacitor) build**. This matters: the mobile app ships the **same `dist/` bundle** (`webDir: 'dist'` in `capacitor.config.ts`) and renders the same untrusted relay content, so it is exposed to the same injection vectors. The OS app sandbox isolates the app from *other apps* — it does **not** stop a same-origin injected script from reading this app's own `localStorage` (nsec, NWC string, Cashu seed) or IndexedDB (Cashu ecash proofs). A CSP is therefore required on mobile as much as on web. Because Capacitor serves the bundle from a local scheme (`https://localhost` on Android, `capacitor://localhost` on iOS), HTTP response headers never reach the mobile WebView. The CSP is therefore defined in **three places that must be kept in sync**: - **`index.html`** — a `` tag. This is the one that reaches the **native build** (and also applies on web). It allowlists Capacitor's local origins so asset loading/live-reload still work. - **`public/_headers`** — HTTP header for nsite / static-host / Netlify `_headers`-style web hosting. - **`netlify.toml`** — HTTP header (`[[headers]]`) for Netlify. The header versions additionally carry `frame-ancestors 'none'` and `upgrade-insecure-requests`, which are ignored when delivered via `` (so they live only in the header copies). The meta copy allowlists `https://localhost capacitor://localhost ionic://localhost` on `script-src`/`style-src`/`img-src`/`connect-src` for Capacitor compatibility. Effective policy (header form): ``` default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; # no 'unsafe-inline', no 'unsafe-eval' style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: blob: https:; font-src 'self' https://fonts.gstatic.com data:; connect-src 'self' https: wss:; media-src 'self' blob: https:; frame-src 'self' https:; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; # header-only (ignored in ) upgrade-insecure-requests # header-only (ignored in ) ``` Notes and known trade-offs: - **`script-src 'self'`** — no inline scripts. The boot crash-guard was moved out of `index.html` into `public/boot-guard.js` specifically so we could drop `'unsafe-inline'`. `'wasm-unsafe-eval'` is included because the Bitcoin/Cashu crypto libraries use WebAssembly; it does **not** permit `eval`/`new Function`. - **`style-src 'unsafe-inline'`** — required by Tailwind/shadcn and Leaflet inline styles. This means **CSS injection is not blocked by CSP**; the app must sanitize any untrusted value that reaches a style/`url()` (see below). - **`connect-src`/`img-src`/`media-src` allow broad `https:`/`wss:`** — by design. Users add their own relays (arbitrary `wss://`), Blossom media hosts, and Cashu mints; images live on arbitrary hosts. A fixed allowlist would break core decentralized functionality. `object-src`/`base-uri`/`frame-ancestors` are locked down to `'none'` where it costs nothing. Additional headers: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: geolocation=(self), camera=(self), microphone=()`. ### Untrusted-data rendering - **React/JSX** auto-escapes all interpolated strings — the default rendering path. Do not bypass it. - **Leaflet map popups & marker icons** (`src/components/SpotMap.tsx`) are the one place we build DOM by hand. Every untrusted field (spot/tour/crew/event titles & locations, meeting points, BTC Map name/address/hours/phone/ description) is written via `textContent`, never `innerHTML`. A `popupRow()` helper enforces this. Image/website fields are validated before use. - Rich Nostr note content is rendered through the shared `NoteContent` component/tokenizer rather than raw HTML. ### URL & CSS sanitization - `src/lib/sanitizeUrl.ts` — `sanitizeUrl()` allows only `http(s)` URLs and returns a parsed href (quotes percent-encoded, safe inside attributes and `url()`); it returns `null` for `javascript:`, `data:`, and malformed input. Companion helpers: `sanitizeTel()`, `sanitizeEmail()`, `urlDomain()`. - Any event/API-sourced URL (profile `picture`/`banner`/`website`, crew image, BTC Map website, social links, etc.) is passed through `sanitizeUrl()` before it touches an `href`/`src`/CSS value. ### Author-scoped queries for trust-sensitive data Addressable (kinds 30000–39999) and replaceable events are queried with the author's pubkey as part of the trust key — a `d` tag alone is **not** a trust boundary. Detail routes carry the author pubkey in the URL (e.g. `/crew/:pubkey/:identifier`, `/spot/:pubkey/:identifier`) so handlers can build author-scoped filters. Public UGC feeds (kind 1, reactions, zaps) are intentionally *not* author-filtered. ### Lightning / NWC exposure - NWC connection strings are spend credentials the app must hold to send zaps. The "Connect NWC Wallet" dialog (`src/components/WalletModal.tsx`) prominently instructs users to set a small **daily budget** in their wallet, scope the connection to **`pay_invoice` only**, and revoke it any time. This caps the worst-case loss from a compromised connection. - Prefer wallets/flows that let you set a spending budget. ### Admin moderation / takedowns Two hardcoded admin pubkeys (`src/lib/admins.ts`, `ADMIN_PUBKEYS`) can take down any listing — spots, crews, tours, events, shops, Lab profiles (coaches, filmers, editors, photographers, builders, artists, graffiti, tattoo, trainers, musicians), and gear/marketplace listings. A takedown publishes both a NIP-09 kind:5 deletion request **and** a NIP-32 kind:1985 `hidden` moderation label authored by the admin. The client-side layer (`useAdminModeration`, wired into every list/detail hook via React Query `select`) then hides that content across the app, so takedowns are reliable even when a relay won't honor a cross-author NIP-09 request. **Trust boundary:** only labels whose `pubkey` is in the hardcoded admin list are honored — this is re-verified client-side in `useAdminModeration` and is never derived from a `d` tag or any attacker-controllable value. Auditors should confirm the admin list is the only source of moderation authority. ### Tooling - A custom ESLint rule (`eslint-rules/no-inline-script.js`) forbids inline `