SkateSpots/SECURITY.md
shakespeare.diy 82a74e01e0 Extend admin takedown to Lab profiles and gear/marketplace listings
Completes the admin moderation coverage from the previous commit.

Lab profiles (coaches, filmers, editors, photographers, builders, artists,
graffiti, tattoo, trainers, musicians):
- useLabProfiles / useLabProfile and useCoaches / useCoach now filter out
  admin-hidden entries (list) and 404 hidden ones (detail) via the shared
  moderation layer. Added filterAdminHiddenMixed() for the mixed-kind Lab
  union (kind read from each item's event).
- CoachDetailPage (the shared Lab detail page) now shows the delete/takedown
  card to admins too, not just the owner, with admin-specific copy.
  DeleteEntityDialog already handles the owner-or-admin logic.

Gear / marketplace listings (NIP-99, kind 30402):
- useMarketplaceItems / useMarketplaceAtSpot filter out admin-hidden listings.
- GearSwapItemCard: admins get a "Remove (admin)" action in the card menu with
  a confirmation dialog that routes through useDeleteEntity (NIP-09 delete +
  hidden label). Report stays for non-owners.

SECURITY.md: updated the admin-moderation coverage list and auditor file map.
Build + lint pass, no console errors.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-08-18 19:17:16 +07:00

276 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `<meta http-equiv="Content-Security-Policy">` 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 `<meta>`
(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 <meta>)
upgrade-insecure-requests # header-only (ignored in <meta>)
```
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
`<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`, and Cashu ecash proofs live
in IndexedDB (native build), in plaintext.** Any script running on the origin
can read them — on **both web and mobile**. 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 shipped in the bundle
so it also covers mobile + 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` |
| Cashu ecash (native only): proofs in IndexedDB `cashu-wallet`, seed in `cashu-mnemonic` | `src/hooks/wallet/useCashu.ts`, `src/lib/platform.ts` |
| Admin authority / moderation / takedowns | `src/lib/admins.ts`, `src/hooks/useAdminModeration.ts`, `src/hooks/useDeleteEntity.ts`, `src/components/DeleteEntityDialog.tsx`, `src/components/GearSwapItemCard.tsx` |
| CSP & security headers | `index.html` (`<meta>` — covers mobile), `public/_headers`, `netlify.toml`, `public/boot-guard.js` |
| Native wrapper (same bundle) | `capacitor.config.ts` (`webDir: 'dist'`) |
| NIP-19 routing | `src/pages/NIP19Page.tsx`, `src/AppRouter.tsx` |
| Custom lint rules | `eslint-rules/` |
Thanks for helping keep the skate community safe. 🛹