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:
co-authored by
Claude Fable 5
parent
51647b21cd
commit
206d5fe8cf
@@ -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>({
|
||||
|
||||
Reference in New Issue
Block a user