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
|
- **Lightning wallet credentials.** NWC connection strings
|
||||||
(`nostr+walletconnect://…`) are stored in `localStorage` under
|
(`nostr+walletconnect://…`) are stored in `localStorage` under
|
||||||
`nwc-connections`. The embedded secret authorizes `pay_invoice`.
|
`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
|
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
|
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
|
### Content Security Policy
|
||||||
|
|
||||||
A restrictive CSP is served via HTTP headers on the **web** build only, so it
|
The CSP applies to **both the web build and the native (Capacitor) build**.
|
||||||
does not interfere with the native Capacitor build (which relies on OS-level
|
This matters: the mobile app ships the **same `dist/` bundle** (`webDir: 'dist'`
|
||||||
sandboxing and a custom asset scheme). It is defined in **two places that must
|
in `capacitor.config.ts`) and renders the same untrusted relay content, so it is
|
||||||
be kept in sync**:
|
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)
|
Because Capacitor serves the bundle from a local scheme
|
||||||
- `netlify.toml` (`[[headers]]` block)
|
(`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';
|
default-src 'none';
|
||||||
|
|
@ -122,8 +150,8 @@ manifest-src 'self';
|
||||||
object-src 'none';
|
object-src 'none';
|
||||||
base-uri 'none';
|
base-uri 'none';
|
||||||
form-action 'self';
|
form-action 'self';
|
||||||
frame-ancestors 'none';
|
frame-ancestors 'none'; # header-only (ignored in <meta>)
|
||||||
upgrade-insecure-requests
|
upgrade-insecure-requests # header-only (ignored in <meta>)
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes and known trade-offs:
|
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
|
These are documented limitations of a client-side Nostr/Lightning app, not
|
||||||
oversights:
|
oversights:
|
||||||
|
|
||||||
- **`nsec` and NWC strings live in `localStorage` in plaintext.** Any script
|
- **`nsec` and NWC strings live in `localStorage`, and Cashu ecash proofs live
|
||||||
running on the origin can read them. Encrypting them client-side offers no
|
in IndexedDB (native build), in plaintext.** Any script running on the origin
|
||||||
real protection (the decryption key would live in the same JS context). The
|
can read them — on **both web and mobile**. Encrypting them client-side offers
|
||||||
meaningful mitigation is preventing script injection (CSP + no-`innerHTML`
|
no real protection (the decryption key would live in the same JS context). The
|
||||||
discipline) and offering extension/bunker login for users who want keys off
|
meaningful mitigation is preventing script injection (CSP shipped in the bundle
|
||||||
the page.
|
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,
|
- **Broad `connect-src`/`img-src`.** Necessary for user-chosen relays, mints,
|
||||||
and media hosts. This permits data exfiltration *if* script execution is ever
|
and media hosts. This permits data exfiltration *if* script execution is ever
|
||||||
achieved — which is why preventing XSS is the top priority.
|
achieved — which is why preventing XSS is the top priority.
|
||||||
|
|
@ -221,7 +250,9 @@ oversights:
|
||||||
| Rich text rendering | `src/components/NoteContent.tsx` |
|
| Rich text rendering | `src/components/NoteContent.tsx` |
|
||||||
| Login / key handling | `src/components/auth/`, Nostrify login (`nostr:login`) |
|
| Login / key handling | `src/components/auth/`, Nostrify login (`nostr:login`) |
|
||||||
| Lightning / NWC | `src/hooks/useNWC.ts`, `src/components/WalletModal.tsx` |
|
| 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` |
|
| NIP-19 routing | `src/pages/NIP19Page.tsx`, `src/AppRouter.tsx` |
|
||||||
| Custom lint rules | `eslint-rules/` |
|
| Custom lint rules | `eslint-rules/` |
|
||||||
|
|
||||||
|
|
|
||||||
37
index.html
37
index.html
|
|
@ -3,8 +3,41 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<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 -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" href="/manifest.webmanifest">
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue