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>
This commit is contained in:
shakespeare.diy 2026-08-16 22:30:19 +07:00
parent d9cf98848e
commit df6b6fdf92
2 changed files with 82 additions and 18 deletions

View file

@ -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 `<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';
@ -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 <meta>)
upgrade-insecure-requests # header-only (ignored in <meta>)
```
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` (`<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/` |

View file

@ -3,8 +3,41 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<!-- CSP intentionally omitted: app is sandboxed by Android/iOS at the OS level.
A restrictive CSP here breaks Capacitor's internal asset loading scheme. -->
<!--
Content-Security-Policy (defense-in-depth) — shipped in the HTML so it
applies to BOTH the web build AND the native Capacitor build.
Why a <meta> tag and not just HTTP headers: Capacitor loads the bundle
from a local scheme (https://localhost on Android, capacitor://localhost
on iOS), so the server headers in public/_headers / netlify.toml never
reach the mobile WebView. The OS sandbox isolates the app from OTHER apps;
it does NOT stop a same-origin injected script from reading this app's own
IndexedDB (Cashu ecash proofs) or localStorage (nsec, NWC string, Cashu
seed). A CSP is therefore just as important on mobile as on web.
Capacitor's local origins/schemes are allowlisted below so asset loading,
the local dev server, and live-reload continue to work. Notably there is
no 'unsafe-inline'/'unsafe-eval' on script-src (only 'wasm-unsafe-eval'
for the Bitcoin/Cashu WASM), which is the directive that actually blocks
injected <script>/onerror payloads. Keep this in sync with
public/_headers and netlify.toml.
-->
<meta http-equiv="Content-Security-Policy" content="
default-src 'none';
script-src 'self' 'wasm-unsafe-eval' https://localhost capacitor://localhost ionic://localhost;
style-src 'self' 'unsafe-inline' https://unpkg.com https://localhost capacitor://localhost ionic://localhost;
img-src 'self' data: blob: https: https://localhost capacitor://localhost ionic://localhost;
font-src 'self' data: https://fonts.gstatic.com;
connect-src 'self' https: wss: https://localhost capacitor://localhost ionic://localhost;
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'
">
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.webmanifest">