- 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>
10 KiB
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:
- A clear description of the issue and its impact.
- Reproduction steps (a minimal PoC event/payload is ideal).
- 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.organd 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 inlocalStorage. 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 inlocalStorage(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 inlocalStorageundernwc-connections. The embedded secret authorizespay_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:
- Never inject untrusted data as markup. No
dangerouslySetInnerHTML,innerHTML,insertAdjacentHTML, ordocument.writewith event/API/URL data. React's JSX auto-escaping is the default; where we build DOM manually (e.g. Leaflet popups) we usedocument.createElement+textContent. - Content Security Policy as defense-in-depth (see below).
- URL sanitization for any event/API URL before it reaches an
href/src/ CSSurl(). - 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_headersstyle)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 ofindex.htmlintopublic/boot-guard.jsspecifically so we could drop'unsafe-inline'.'wasm-unsafe-eval'is included because the Bitcoin/Cashu crypto libraries use WebAssembly; it does not permiteval/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-srcallow broadhttps:/wss:— by design. Users add their own relays (arbitrarywss://), Blossom media hosts, and Cashu mints; images live on arbitrary hosts. A fixed allowlist would break core decentralized functionality.object-src/base-uri/frame-ancestorsare 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 viatextContent, neverinnerHTML. ApopupRow()helper enforces this. Image/website fields are validated before use. - Rich Nostr note content is rendered through the shared
NoteContentcomponent/tokenizer rather than raw HTML.
URL & CSS sanitization
src/lib/sanitizeUrl.ts—sanitizeUrl()allows onlyhttp(s)URLs and returns a parsed href (quotes percent-encoded, safe inside attributes andurl()); it returnsnullforjavascript:,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 throughsanitizeUrl()before it touches anhref/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 topay_invoiceonly, 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, keepingscript-src 'self'enforceable.
Known residual risks (accepted)
These are documented limitations of a client-side Nostr/Lightning app, not oversights:
nsecand NWC strings live inlocalStoragein 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-innerHTMLdiscipline) 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. 🛹