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:
parent
51647b21cd
commit
206d5fe8cf
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user