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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-16 22:20:47 +07:00
parent acfbff6a07
commit d9cf98848e
2 changed files with 233 additions and 1 deletions

228
SECURITY.md Normal file
View file

@ -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
`<script>` in HTML, keeping `script-src 'self'` enforceable.
---
## Known residual risks (accepted)
These are documented limitations of a client-side Nostr/Lightning app, not
oversights:
- **`nsec` and NWC strings live in `localStorage` in plaintext.** Any script
running on the origin can read them. Encrypting them client-side offers no
real protection (the decryption key would live in the same JS context). The
meaningful mitigation is preventing script injection (CSP + no-`innerHTML`
discipline) and offering extension/bunker login for users who want keys off
the page.
- **Broad `connect-src`/`img-src`.** Necessary for user-chosen relays, mints,
and media hosts. This permits data exfiltration *if* script execution is ever
achieved — which is why preventing XSS is the top priority.
- **`style-src 'unsafe-inline'`.** Required by the CSS framework; CSS-injection
defense is handled by input sanitization rather than CSP.
---
## For auditors: where to look first
| Area | Files |
|------|-------|
| Manual DOM / map popups (highest XSS risk) | `src/components/SpotMap.tsx` |
| URL/CSS sanitization helpers | `src/lib/sanitizeUrl.ts` |
| Event parsers (untrusted → typed) | `src/lib/types.ts` |
| Rich text rendering | `src/components/NoteContent.tsx` |
| Login / key handling | `src/components/auth/`, Nostrify login (`nostr:login`) |
| Lightning / NWC | `src/hooks/useNWC.ts`, `src/components/WalletModal.tsx` |
| CSP & security headers | `public/_headers`, `netlify.toml`, `index.html`, `public/boot-guard.js` |
| NIP-19 routing | `src/pages/NIP19Page.tsx`, `src/AppRouter.tsx` |
| Custom lint rules | `eslint-rules/` |
Thanks for helping keep the skate community safe. 🛹

View file

@ -5,9 +5,13 @@
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "SAMEORIGIN"
# Keep this Content-Security-Policy in sync with public/_headers.
# Web-only hardening; the native Capacitor build is unaffected (it has no CSP).
Content-Security-Policy = "default-src 'none'; script-src 'self' 'wasm-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"
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
Permissions-Policy = "geolocation=(self), camera=(self), microphone=()"
[[headers]]
for = "/assets/*"