From df6b6fdf9257fc76b9d52c1a802f0566a2f4cb86 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Sun, 16 Aug 2026 22:30:19 +0700 Subject: [PATCH] Ship CSP in the bundle so it also protects the native (mobile) build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- SECURITY.md | 63 +++++++++++++++++++++++++++++++++++++++-------------- index.html | 37 +++++++++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 18 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index eaebf62..5422f83 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -65,6 +65,16 @@ client necessarily handles secrets in the browser: - **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 @@ -98,15 +108,33 @@ Everything below is **untrusted** and must be treated as attacker-controlled: ### 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**: +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. -- `public/_headers` (nsite / static-host / Netlify `_headers` style) -- `netlify.toml` (`[[headers]]` block) +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**: -Current policy: +- **`index.html`** — a `` 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 `` +(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'; @@ -122,8 +150,8 @@ manifest-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; -frame-ancestors 'none'; -upgrade-insecure-requests +frame-ancestors 'none'; # header-only (ignored in ) +upgrade-insecure-requests # header-only (ignored in ) ``` Notes and known trade-offs: @@ -197,12 +225,13 @@ intentionally *not* author-filtered. These are documented limitations of a client-side Nostr/Lightning app, not oversights: -- **`nsec` and NWC strings live in `localStorage` in 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-`innerHTML` - discipline) and offering extension/bunker login for users who want keys off - the page. +- **`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. @@ -221,7 +250,9 @@ oversights: | 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` | +| 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` (`` — 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/` | diff --git a/index.html b/index.html index 8329cc6..659fe39 100644 --- a/index.html +++ b/index.html @@ -3,8 +3,41 @@ - + + +