fix(ui): https app launches and the nostr bridge follow the frame's real origin
Three launcher/bridge defects combined to make HTTPS dashboards look
broken while HTTP ones worked:
1. portAuth() looked the launch port up under the name the user clicks
('mempool-web', 'lnd', 'bitcoin-knots'…), but the signed catalog
declares those ports under the manifest id that owns them
(archy-mempool-web, lnd-ui, bitcoin-ui). The lookup missed,
portIsGateFronted answered false, and an HTTPS dashboard handed app
frames http:// URLs — blocked as mixed content: mempool and IndeeHub
'did not connect', bitcoin knots/core opened http:// in a new tab.
Resolution now follows launch aliases, then a port-wide catalog scan
that only answers when every declarer of that port agrees (a port
any app publishes as plain HTTP is never upgraded to https).
2. The signed-catalog cache was only warmed by the Store/Discover
views, so a user who went straight to My Apps launched apps with an
empty cache. Warmed at dashboard mount now — fetchAppCatalog()
already memoizes with a 1h TTL.
3. The NIP-07 bridge compared event.origin for strict equality with the
recorded (http) app URL and replied to the recorded URL as the
postMessage targetOrigin — both break the moment a frame is scheme-
upgraded (cached HSTS did exactly that): every nostr request was
silently dropped and replies to the stale origin threw. The bridge
now matches host+port (scheme deliberately ignored) and always
replies to event.origin — the frame's real origin.
Unit tests cover alias resolution (incl. bitcoin-knots→8334→https),
the conservative port-scan, and scheme-agnostic sender matching.
This commit is contained in:
@@ -117,6 +117,7 @@ import { useSpotlightStore } from '@/stores/spotlight'
|
||||
import { useCLIStore } from '@/stores/cli'
|
||||
import { useMessageToast } from '@/composables/useMessageToast'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { fetchAppCatalog } from './views/discover/curatedApps'
|
||||
import { useScreensaverStore } from '@/stores/screensaver'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
import { startRemoteRelay, stopRemoteRelay } from '@/api/remote-relay'
|
||||
@@ -396,6 +397,13 @@ function onVisibilityChange() {
|
||||
|
||||
onMounted(async () => {
|
||||
syncKioskSafeArea()
|
||||
// Warm the signed-catalog cache before any app launch needs it: port auth
|
||||
// (gate-fronted ⇒ TLS on the app port) decides whether an app frame opens
|
||||
// over https on an HTTPS dashboard. The cache used to be filled only by
|
||||
// the Store/Discover views, so a user who went straight to My Apps got an
|
||||
// http:// frame URL — blocked as mixed content (mempool/indeehub "did not
|
||||
// connect", 2026-09-01). fetchAppCatalog() memoizes with a 1h TTL.
|
||||
void fetchAppCatalog()
|
||||
// Light app-wide mesh poll so a freshly plugged-in radio surfaces the
|
||||
// setup modal on any page (the Mesh view's own poll takes over there).
|
||||
useMeshStore().startGlobalDetection()
|
||||
|
||||
@@ -29,7 +29,7 @@ vi.mock('@/router', () => ({
|
||||
|
||||
vi.stubGlobal('open', mockWindowOpen)
|
||||
|
||||
import { useAppLauncherStore } from '../appLauncher'
|
||||
import { useAppLauncherStore, senderMatchesApp } from '../appLauncher'
|
||||
|
||||
describe('useAppLauncherStore', () => {
|
||||
beforeEach(() => {
|
||||
@@ -448,4 +448,21 @@ describe('useAppLauncherStore', () => {
|
||||
vi.runAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('NIP-07 sender origin matching', () => {
|
||||
it('accepts a scheme-upgraded frame (HSTS) as the opened app', () => {
|
||||
// Regression (2026-09-01): the stored app URL was http:// but the
|
||||
// browser loaded the frame as https:// — strict origin equality
|
||||
// dropped every nostr sign-in from the upgraded frame.
|
||||
expect(senderMatchesApp('http://framework-pt.local:7778', 'https://framework-pt.local:7778')).toBe(true)
|
||||
expect(senderMatchesApp('https://framework-pt.local:7778', 'http://framework-pt.local:7778')).toBe(true)
|
||||
})
|
||||
|
||||
it('still rejects a different host or port', () => {
|
||||
expect(senderMatchesApp('http://framework-pt.local:7778', 'https://evil.example:7778')).toBe(false)
|
||||
expect(senderMatchesApp('http://framework-pt.local:7778', 'https://framework-pt.local:7777')).toBe(false)
|
||||
expect(senderMatchesApp('http://framework-pt.local:7778', 'null')).toBe(false)
|
||||
expect(senderMatchesApp('', 'https://framework-pt.local:7778')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,24 @@ function openExternal(launchUrl: string) {
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
/** Whether a postMessage sender's origin belongs to the app the launcher
|
||||
* actually opened. Same hostname and port are REQUIRED; the SCHEME is
|
||||
* deliberately not compared: a browser with cached HSTS (or any scheme
|
||||
* upgrade) loads a stored http:// app URL as https://, and strict equality
|
||||
* silently dropped every nostr request from the upgraded frame — nostr
|
||||
* sign-in on IndeeHub died exactly there over HTTPS (2026-09-01). */
|
||||
export function senderMatchesApp(appUrl: string, senderOrigin: string): boolean {
|
||||
let expected: URL
|
||||
let sender: URL
|
||||
try {
|
||||
expected = new URL(appUrl, 'http://localhost/')
|
||||
sender = new URL(senderOrigin)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return sender.hostname === expected.hostname && sender.port === expected.port
|
||||
}
|
||||
|
||||
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
|
||||
const NEW_TAB_PORTS = new Set([
|
||||
'23000', // BTCPay — X-Frame-Options: DENY
|
||||
@@ -393,19 +411,11 @@ 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
|
||||
// Only the app we actually opened may drive this bridge — see
|
||||
// senderMatchesApp for why the scheme is deliberately not compared.
|
||||
if (!senderMatchesApp(url.value, event.origin)) return
|
||||
|
||||
const origin = url.value || 'unknown'
|
||||
const origin = event.origin
|
||||
|
||||
// Check if app has a per-app identity stored (from identity picker)
|
||||
const IDENTITY_KEY = 'archipelago_app_identity_'
|
||||
|
||||
@@ -265,7 +265,7 @@ function closeRouteSession() {
|
||||
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
|
||||
|
||||
const identity = useAppIdentity(appId, iframeRef, showIdentityPicker)
|
||||
const nostrBridge = useNostrBridge(identity.getStoredIdentity, () => appUrl.value)
|
||||
const nostrBridge = useNostrBridge(identity.getStoredIdentity)
|
||||
|
||||
// --- Display mode ---
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
/** Composable for NIP-07 Nostr signing bridge between parent and iframe */
|
||||
/** Composable for NIP-07 Nostr signing between parent and iframe apps.
|
||||
*
|
||||
* Replies always target event.origin — the frame's REAL origin. The app's
|
||||
* recorded URL can carry a stale scheme (HSTS-upgraded http app on an HTTPS
|
||||
* dashboard); targeting it makes postMessage throw and the app never sees
|
||||
* its response. */
|
||||
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import type { SelectedIdentity } from './useAppIdentity'
|
||||
|
||||
export function useNostrBridge(
|
||||
getStoredIdentity: () => SelectedIdentity | null,
|
||||
getAppUrl: () => string,
|
||||
) {
|
||||
async function handleNostrRequest(event: MessageEvent) {
|
||||
const { id, method, params } = event.data
|
||||
@@ -43,14 +47,15 @@ export function useNostrBridge(
|
||||
else if (method === 'nip44.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
|
||||
else if (method === 'nip44.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
|
||||
else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
|
||||
const url = getAppUrl()
|
||||
const targetOrigin = url ? new URL(url).origin : '*'
|
||||
source.postMessage({ type: 'nostr-response', id, result }, targetOrigin)
|
||||
// Reply to the sender's REAL origin, never to the stored app URL:
|
||||
// a scheme-upgraded frame (HSTS, or any future upgrade) makes the
|
||||
// stored http:// URL a stale targetOrigin — postMessage then throws
|
||||
// and the app never receives its response. nostr sign-in on IndeeHub
|
||||
// over HTTPS died exactly there (2026-09-01).
|
||||
source.postMessage({ type: 'nostr-response', id, result }, event.origin || '*')
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err)
|
||||
const url = getAppUrl()
|
||||
const targetOrigin = url ? new URL(url).origin : '*'
|
||||
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, targetOrigin)
|
||||
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, event.origin || '*')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { __setSignedCatalogForTests, portAuth, portIsGateFronted, type SignedAppCatalog } from '../curatedApps'
|
||||
|
||||
/** Catalog fragments mirroring the live signed catalog's port declarations
|
||||
* (releases/app-catalog.json, 2026-09-01). */
|
||||
const catalog = (apps: SignedAppCatalog['apps']): SignedAppCatalog => ({ apps })
|
||||
|
||||
const FULL = catalog({
|
||||
'archy-mempool-web': {
|
||||
version: '3.0.1',
|
||||
manifest: { app: { id: 'archy-mempool-web', ports: [{ host: 4080, container: 8080, auth: 'gated' }] } },
|
||||
},
|
||||
'mempool': {
|
||||
version: '3.0.0',
|
||||
manifest: { app: { id: 'mempool', ports: [{ host: 4080, container: 8080, auth: 'gated' }] } },
|
||||
},
|
||||
'lnd-ui': {
|
||||
version: '1.0.0',
|
||||
manifest: { app: { id: 'lnd-ui', ports: [{ host: 18083, container: 18083, auth: 'gated' }] } },
|
||||
},
|
||||
'bitcoin-ui': {
|
||||
version: '1.0.0',
|
||||
manifest: { app: { id: 'bitcoin-ui', ports: [{ host: 8334, container: 8334, auth: 'gated' }] } },
|
||||
},
|
||||
'bitcoin-knots': {
|
||||
version: '29.3',
|
||||
manifest: { app: { id: 'bitcoin-knots', ports: [{ host: 8332, container: 8332, auth: 'none' }] } },
|
||||
},
|
||||
'electrs-ui': {
|
||||
version: '1.0.0',
|
||||
manifest: { app: { id: 'electrs-ui', ports: [{ host: 50002, container: 50002, auth: 'gated' }] } },
|
||||
},
|
||||
})
|
||||
|
||||
afterEach(() => __setSignedCatalogForTests(null))
|
||||
|
||||
describe('portAuth', () => {
|
||||
it('resolves the UI port through the launch alias, not just the app id', () => {
|
||||
__setSignedCatalogForTests(FULL)
|
||||
// 'mempool-web' has no catalog entry of its own; archy-mempool-web owns 4080.
|
||||
expect(portIsGateFronted('mempool-web', 4080)).toBe(true)
|
||||
// 'bitcoin-knots' declares 8332 (auth none) but its UI port 8334 is owned
|
||||
// by bitcoin-ui — the alias must find it, or the new-tab button hands
|
||||
// out an http:// URL on an HTTPS dashboard (2026-09-01 report).
|
||||
expect(portIsGateFronted('bitcoin-knots', 8334)).toBe(true)
|
||||
expect(portIsGateFronted('lnd', 18083)).toBe(true)
|
||||
expect(portIsGateFronted('electrs', 50002)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a port the app itself publishes as plain HTTP off the gate', () => {
|
||||
__setSignedCatalogForTests(FULL)
|
||||
expect(portAuth('bitcoin-knots', 8332)).toBe('none')
|
||||
expect(portIsGateFronted('bitcoin-knots', 8332)).toBe(false)
|
||||
})
|
||||
|
||||
it('answers null for unknown apps and ports (never assume TLS)', () => {
|
||||
__setSignedCatalogForTests(FULL)
|
||||
expect(portAuth('never-installed-app', 1234)).toBeNull()
|
||||
expect(portIsGateFronted('bitcoin-ui', 9999)).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to a unanimous port-wide scan for unknown ids', () => {
|
||||
__setSignedCatalogForTests(FULL)
|
||||
// No alias for this id, but every declarer of 4080 says gated.
|
||||
expect(portIsGateFronted('some-future-alias', 4080)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses the port-wide scan when declarers disagree (no TLS guess)', () => {
|
||||
__setSignedCatalogForTests(catalog({
|
||||
'app-a': { version: '1', manifest: { app: { ports: [{ host: 7000, auth: 'gated' }] } } },
|
||||
'app-b': { version: '1', manifest: { app: { ports: [{ host: 7000, auth: 'none' }] } } },
|
||||
}))
|
||||
expect(portAuth('unknown-app', 7000)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null without a warmed catalog (cache miss, not a guess)', () => {
|
||||
expect(portAuth('mempool-web', 4080)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -79,15 +79,60 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[]
|
||||
* after fetchAppCatalog() has run. Test-hookable. */
|
||||
let signedCatalogCache: SignedAppCatalog | null = null
|
||||
|
||||
/** Launch aliases → the catalog app id that OWNS the UI port.
|
||||
*
|
||||
* The launcher knows apps by several historical names (`mempool-web`, `lnd`,
|
||||
* `electrs`…); the signed catalog knows them by manifest id. Without this
|
||||
* map the port-auth lookup below misses, `portIsGateFronted` answers false,
|
||||
* and an HTTPS dashboard hands the app session an http:// frame URL — which
|
||||
* the browser then blocks outright as mixed content. That is exactly how
|
||||
* Mempool and IndeeHub “did not connect” over HTTPS while working fine over
|
||||
* HTTP (2026-09-01). */
|
||||
const CATALOG_APP_ID_ALIASES: Record<string, string> = {
|
||||
'mempool-web': 'archy-mempool-web',
|
||||
'mempool-electrs': 'electrs-ui',
|
||||
'electrs': 'electrs-ui',
|
||||
'archy-electrs-ui': 'electrs-ui',
|
||||
'lnd': 'lnd-ui',
|
||||
'archy-lnd-ui': 'lnd-ui',
|
||||
'bitcoin-knots': 'bitcoin-ui',
|
||||
'bitcoin-core': 'bitcoin-ui',
|
||||
'fedimintd': 'fedimint',
|
||||
'immich_server': 'immich',
|
||||
}
|
||||
|
||||
/** Port auth for an app's host port, from the signed catalog's embedded
|
||||
* manifest. `gated`/`open` = the node's app gate owns the port and serves
|
||||
* TLS on it; `none`/`local` = container-published plain HTTP; null = app
|
||||
* unknown to the signed catalog (legacy curated installs). */
|
||||
* unknown to the signed catalog (legacy curated installs).
|
||||
*
|
||||
* Resolution order: the app's own manifest, then its alias (the manifest
|
||||
* that actually owns the UI port), then — only for ports no known id
|
||||
* declares — a port-wide scan of the catalog. The scan must be UNANIMOUS:
|
||||
* a host port that any app publishes as plain HTTP (`none`) must never be
|
||||
* answered `gated`, or an https frame URL would point at a port that never
|
||||
* serves TLS. */
|
||||
export function portAuth(appId: string, hostPort: number | string): string | null {
|
||||
const ports = signedCatalogCache?.apps?.[appId]?.manifest?.app?.ports
|
||||
if (!Array.isArray(ports)) return null
|
||||
const hit = ports.find(p => String(p.host) === String(hostPort))
|
||||
return hit?.auth ?? null
|
||||
const apps = signedCatalogCache?.apps
|
||||
if (!apps) return null
|
||||
const alias: string | undefined = CATALOG_APP_ID_ALIASES[appId]
|
||||
const ids: string[] = alias === undefined || alias === appId ? [appId] : [appId, alias]
|
||||
for (const id of ids) {
|
||||
const ports = apps[id]?.manifest?.app?.ports
|
||||
if (!Array.isArray(ports)) continue
|
||||
const hit = ports.find(p => String(p.host) === String(hostPort))
|
||||
if (hit?.auth) return hit.auth
|
||||
}
|
||||
let found: string | null = null
|
||||
for (const entry of Object.values(apps)) {
|
||||
const ports = entry?.manifest?.app?.ports
|
||||
if (!Array.isArray(ports)) continue
|
||||
const hit = ports.find(p => String(p.host) === String(hostPort))
|
||||
if (!hit?.auth) continue
|
||||
if (found === null) found = hit.auth
|
||||
else if (found !== hit.auth) return null
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** Whether an app's host port is fronted by the node's app gate (and so
|
||||
|
||||
Reference in New Issue
Block a user