diff --git a/docs/1.8.0-RELEASE-HARDENING-PLAN.md b/docs/1.8.0-RELEASE-HARDENING-PLAN.md index cd154ce6..115753da 100644 --- a/docs/1.8.0-RELEASE-HARDENING-PLAN.md +++ b/docs/1.8.0-RELEASE-HARDENING-PLAN.md @@ -141,15 +141,14 @@ modules; production request/boot paths are essentially panic-free. The real risk The untrusted mesh/LoRa chat path is **safe** (interpolation, no `v-html` — good). The real issues are the app-bridge origin model and a bloated bundle. -- [ ] 🟠 **Validate `event.origin` + add consent gates in the NIP-07 nostr bridge.** - `stores/appLauncher.ts:385-490` derives the caller from the launcher's own URL, never - `event.origin`, and `getPublicKey`/`nip04.decrypt`/`nip44.decrypt` have no consent gate → - any co-resident iframe can deanonymize the nostr identity or use the node as a decryption - oracle while an app is open. Check `event.origin` against the open app's real origin; key - approvals on it; gate decrypt/getPublicKey like `signEvent`. -- [ ] 🟠 **Origin-check the `share-to-mesh` handler.** `App.vue:450-464` acts on - `{type:'share-to-mesh', cid}` from any sender and force-navigates to `/mesh` with the CID - pre-staged. Add `ev.origin === window.location.origin` (as `Chat.vue:95` already does). +- [x] 🟠 **Validate `event.origin` + add consent gates in the NIP-07 nostr bridge.** + DONE 2026-07-02: `handleNostrRequest` rejects senders whose `event.origin` doesn't match + the open app's URL origin, and ALL identity-sensitive methods (`getPublicKey`, `signEvent`, + `nip04`/`nip44` encrypt+decrypt) now go through the consent/approved-origins gate, not just + `signEvent`. Verified present in the built bundle. +- [x] 🟠 **Origin-check the `share-to-mesh` handler.** DONE 2026-07-02: `App.vue` + `onShareToMeshMessage` now requires `ev.origin === window.location.origin` (matching + `Chat.vue`). - [ ] 🟡 **Decide the app-iframe isolation model.** `AppSessionFrame.vue:54` / `AppLauncherOverlay.vue:79` embed apps same-origin with no meaningful `sandbox`; a same-origin app can read the CSRF cookie + `localStorage`. Ideal fix (serve apps from a diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index 69c2ed23..e5f3231a 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -448,6 +448,9 @@ onBeforeUnmount(() => { * stash on mount and stages it as a pending attachment. */ function onShareToMeshMessage(ev: MessageEvent) { + // Same-origin senders only (matches Chat.vue's handler) — otherwise any + // embedded frame can force-navigate the UI to /mesh with a staged CID. + if (ev.origin !== window.location.origin) return const data = ev.data as { type?: string; cid?: string } | null if (!data || data.type !== 'share-to-mesh' || !data.cid) return try { diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index a0bb146f..68f3f43b 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -388,6 +388,18 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { const source = event.source as Window | null if (!source) return + // Only the app we actually opened may drive this bridge. The sender's + // real origin must match the open app's URL origin — without this, any + // co-resident iframe could deanonymize the nostr identity or use the + // node as a decryption oracle while an app happened to be open. + let expectedOrigin: string + try { + expectedOrigin = new URL(url.value, window.location.href).origin + } catch { + return + } + if (event.origin !== expectedOrigin) return + const origin = url.value || 'unknown' // Check if app has a per-app identity stored (from identity picker) @@ -405,6 +417,26 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { } } catch { /* ignore */ } + // Every identity-sensitive method needs consent (or a remembered + // approval for this origin) — not just signEvent. getPublicKey + // deanonymizes; the decrypts turn the node into a decryption oracle. + const CONSENT_METHODS = new Set([ + 'getPublicKey', 'signEvent', + 'nip04.encrypt', 'nip04.decrypt', + 'nip44.encrypt', 'nip44.decrypt', + ]) + if (CONSENT_METHODS.has(method) && !getApprovedOrigins().has(origin)) { + const eventKind = method === 'signEvent' ? (params?.event?.kind as number | undefined) : undefined + const content = method === 'signEvent' ? (params?.event?.content as string | undefined) : undefined + try { + const remember = await requestConsent(title.value || 'App', method, eventKind, content) + if (remember) saveApprovedOrigin(origin) + } catch { + source.postMessage({ type: 'nostr-response', id, error: `User denied ${method} request` }, origin || '*') + return + } + } + try { let result: unknown @@ -420,19 +452,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { result = res.nostr_pubkey } } else if (method === 'signEvent') { - // Check if origin is pre-approved - const approved = getApprovedOrigins() - if (!approved.has(origin)) { - const eventKind = params?.event?.kind as number | undefined - const content = params?.event?.content as string | undefined - try { - const remember = await requestConsent(title.value || 'App', 'signEvent', eventKind, content) - if (remember) saveApprovedOrigin(origin) - } catch { - source.postMessage({ type: 'nostr-response', id, error: 'User denied signing request' }, origin || '*') - return - } - } if (appIdentityId) { // Sign with the app-specific identity's Nostr key const res = await rpcClient.call({