fix(security): origin-check the NIP-07 bridge + share-to-mesh, gate all identity methods behind consent

The nostr bridge derived the caller from the launcher's own URL and
never checked event.origin, so any co-resident iframe could pull the
node's nostr pubkey or use nip04/nip44 decrypt as an oracle while an
app was open. The bridge now rejects senders whose real origin doesn't
match the open app's origin, and every identity-sensitive method
(getPublicKey, signEvent, encrypt/decrypt) requires user consent or a
remembered per-origin approval — previously only signEvent did.

share-to-mesh in App.vue likewise accepted messages from any sender
and force-navigated to /mesh with an attacker-staged CID; it now
requires same-origin, matching Chat.vue's existing handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-02 12:53:41 -04:00
parent 51647b21cd
commit 206d5fe8cf
3 changed files with 43 additions and 22 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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<unknown>({