SkateSpots/SECURITY.md
shakespeare.diy df6b6fdf92 Ship CSP in the bundle so it also protects the native (mobile) build
Follow-up to the map-XSS work. The Capacitor app ships the same dist/ bundle
(webDir: 'dist') and renders the same untrusted relay content, so it shared
the same injection surface — but the CSP only existed as HTTP headers
(public/_headers, netlify.toml), which never reach a WebView loading from a
local scheme. The prior "OS sandbox is enough" comment was wrong: the 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 bearer ecash proofs, native-only wallet).

Changes:
- index.html: add a <meta http-equiv="Content-Security-Policy"> so the policy
  ships inside the bundle and applies to BOTH web and native. script-src stays
  'self' 'wasm-unsafe-eval' (no unsafe-inline/eval). Capacitor local origins
  (https://localhost, capacitor://localhost, ionic://localhost) are allowlisted
  on script/style/img/connect so asset loading & live-reload still work.
  Replaces the old "CSP intentionally omitted" comment. Verified the app boots
  and the map renders with no CSP violations.
- SECURITY.md: correct the CSP section (now three synced locations incl. the
  meta tag; explain the local-scheme reason and the OS-sandbox misconception),
  add Cashu ecash/seed to the threat model and residual-risks, note the shared
  bundle, and extend the auditor file map (useCashu.ts, platform.ts,
  capacitor.config.ts).

Note: the actual injection fix (createElement + textContent in map popups)
already shipped on mobile since it lives in the shared bundle; this commit
adds the defense-in-depth layer that was previously web-only.

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

12 KiB
Raw Blame History

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 — 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.

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
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. 🛹