From d9cf98848e9175e8d8f3cc83ad03a1d9148afae9 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Sun, 16 Aug 2026 22:20:47 +0700 Subject: [PATCH] Add SECURITY.md for auditors + align netlify.toml CSP with public/_headers - Add root SECURITY.md documenting the threat model (plaintext nsec / NWC in localStorage inherent to client-side Nostr), reporting channels (Nostr DM / NIP-05), in/out of scope, the CSP and its trade-offs, untrusted-data rendering rules, URL/CSS sanitization, author-scoped queries, NWC budget guidance, accepted residual risks, and an auditor file map. - netlify.toml now serves the same Content-Security-Policy as public/_headers and uses X-Frame-Options: DENY + Permissions-Policy, so the hardening holds regardless of which static host serves the web build. Co-authored-by: shakespeare.diy --- SECURITY.md | 228 +++++++++++++++++++++++++++++++++++++++++++++++++++ netlify.toml | 6 +- 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..eaebf62 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,228 @@ +# 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`. + +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 + +A restrictive CSP is served via HTTP headers on the **web** build only, so it +does not interfere with the native Capacitor build (which relies on OS-level +sandboxing and a custom asset scheme). It is defined in **two places that must +be kept in sync**: + +- `public/_headers` (nsite / static-host / Netlify `_headers` style) +- `netlify.toml` (`[[headers]]` block) + +Current policy: + +``` +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'; +upgrade-insecure-requests +``` + +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. + +### Tooling + +- A custom ESLint rule (`eslint-rules/no-inline-script.js`) forbids inline + `