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>
276 lines
14 KiB
Markdown
276 lines
14 KiB
Markdown
# 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. 🛹
|