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:
parent
d9cf98848e
commit
df6b6fdf92
2 changed files with 82 additions and 18 deletions
63
SECURITY.md
63
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 `<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/` |
|
||||
|
||||
|
|
|
|||
37
index.html
37
index.html
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue