Summarises the code review + dependency scan: no exploitable client/server issue found; documents why npm audit fix was reverted (broke the build) and which advisories are unreachable/accepted (xlsx export-only, HashRouter open-redirect, MCP client-side, build-tool deps). Fixes the outdated 'never innerHTML' claim (popups use innerHTML with escapeHtml) and updates the contact NIP-05 to the seeded skatespots@skate-spots.org.
19 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@skate-spots.org(seeded platform handle)
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. - Cashu ecash (native build only). The mobile (Capacitor) build enables a
built-in Cashu wallet that the web build disables (
isNativePlatform()gate insrc/hooks/wallet/useCashu.ts). Its bearer proofs live in IndexedDB (databasecashu-wallet) and its seed mnemonic inlocalStorage(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:
- 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
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 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. Popups setinnerHTMLfrom a template in which every dynamic value is passed throughescapeHtml(re-verified 2026-10-12); marker icons and the weather card usetextContent. 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.
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, 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 inlocalStorage, 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-innerHTMLdiscipline) and offering extension/bunker login for users who want keys off the page. (The Cashu wallet seed is a partial exception — see "Known limitations — Cashu wallet" below.)- 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.
Known limitations — Cashu wallet
The built-in Cashu ecash wallet is native-only (browsers block most mints via CORS). Its BIP-39 mnemonic is the master key to all ecash funds, so it gets stronger handling than other secrets:
-
Seed storage location (per platform).
- Native (Android / iOS): the mnemonic is stored in the OS-backed secure
store — Android KeyStore / iOS Keychain — via
capacitor-secure-storage-plugin(src/lib/secureStorage.ts), not in plaintextlocalStorage. Existing users on an older build are migrated transparently on first read (the legacy plaintextcashu-mnemoniccopy is moved into secure storage and the plaintext removed). - Web: falls back to
localStorage(same accepted-risk posture asnsecabove). The wallet is effectively native-only in practice, so web plaintext exposure is a minor surface. - Ecash proofs (the spendable tokens) still live in IndexedDB
(
cashu-wallet) in plaintext on device — a stolen unlocked device can spend them, exactly like cash. Loss of the seed without a backup = permanent loss.
- Native (Android / iOS): the mnemonic is stored in the OS-backed secure
store — Android KeyStore / iOS Keychain — via
-
Backup behavior (Android / native). A freshly auto-generated seed sets a
needsBackupflag that forces a mandatory, non-dismissable backup step (RecoveryPhraseDialoginrequireConfirmmode): the user must reveal the 12 words and explicitly confirm ("I've written it down") before the wallet is usable. This prevents a silently generated seed from being left un-backed-up — clearing app storage without a backup means the funds are unrecoverable. The phrase can be re-viewed any time from the wallet ("View Recovery Phrase"). -
Planned / status. Secure-storage migration is shipped (native seed now in Keychain/KeyStore). Remaining accepted risk: proofs-at-rest in IndexedDB and the web-only
localStoragefallback. Encrypting proofs on-device is tracked as a future hardening item but offers limited value while the key must live in the same JS context (same reasoning asnsecabove).Relevant files:
src/hooks/wallet/useCashu.ts,src/lib/secureStorage.ts,src/components/wallet/RecoveryPhraseDialog.tsx.
Audit log — 2026-10-12 (code review + dependency scan)
A targeted review of the client (src/), the standalone servers (cvm-server,
push-service, ai-proxy, netlify/functions) and the dependency tree.
No exploitable issue was found in app code.
- Injection / XSS: no
eval/new Function;dangerouslySetInnerHTMLappears only in shadcn'schart.tsx(static, developer-defined chart CSS). The hand-built Leaflet popup HTML interpolates values but every dynamic value isescapeHtml'd (verified).document.write(recovery-phrase QR and rider-card print windows) interpolates only canvas data-URLs and i18n strings. No__proto__/ prototype pollution. No secrets in web storage beyond the documentednsec/NWC/Cashu. - Reverse tabnabbing: every
target="_blank"also carriesrel="noopener noreferrer". - SSRF: the only
fetchto a derived host ishttps://<domain>/.well-known/lnurlp/…where the domain comes from a hardcoded platform Lightning address (not user input). Servers do not fetch user-supplied URLs. The push service additionally validates subscription endpoints (isSafePushEndpoint: https-only; blocks localhost + private / link-local / CGNAT ranges). - Auth: NIP-98 (kind 27235) signature + timestamp checks gate push
subscribe/unregister and the AI proxy; zap receipts and owner-grant labels are
signature-verified (
verifyEvent) on both client and servers; the CVM server signature-verifies event content before serving it to agents. - CORS: the AI proxy and push service enforce origin allowlists; the NIP-05
well-known function is intentionally
Access-Control-Allow-Origin: *(public). - Randomness:
Math.randomis used only for UI ids, probe/subscription ids, PoW nonces and privacy timestamp jitter — never for secrets or tokens.
Dependencies (npm audit). npm audit fix updated @remix-run/router (open
redirect), @modelcontextprotocol/sdk, @humanfs/node, valibot and
brace-expansion, but was reverted because it re-resolved the lockfile and
broke the build (a nested @nostrify/nostrify duplicate introduced a type
conflict). Re-assessment: none of the remaining advisories is reachable from the
shipped app —
xlsx(high, prototype pollution + ReDoS, no fix): used only to generate the deck-cost workbook (XLSX.writeFile); imported files are never parsed, so the parsing flaws are unreachable. Accepted.@remix-run/routeropen redirect: unreachable — the app uses HashRouter, so navigation stays inside the#fragment and never crosses to an external URL.@modelcontextprotocol/sdkOAuth flaw: affects MCP clients connecting to untrusted servers;cvm-serveris an MCP server, not a client. Not applicable.braces,@vitest/mocker,@capacitor/cli'suuid,tailwindcss: build/test tooling only — no runtime reach into the served app. Accepted.
A broken build is a larger risk than an unreachable CVE, so the broken fix was reverted rather than forced through.
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 OS Keychain/KeyStore (secure storage), localStorage fallback on web |
src/hooks/wallet/useCashu.ts, src/lib/secureStorage.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. 🛹