From 3347b8b8b9fb226dec7d2848de4be5bfc0b414fd Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 1 Sep 2026 10:29:05 -0400 Subject: [PATCH] fix(ui): https app launches and the nostr bridge follow the frame's real origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- neode-ui/src/App.vue | 8 ++ .../src/stores/__tests__/appLauncher.test.ts | 19 ++++- neode-ui/src/stores/appLauncher.ts | 34 +++++--- neode-ui/src/views/AppSession.vue | 2 +- .../src/views/appSession/useNostrBridge.ts | 21 +++-- .../__tests__/curatedApps.portAuth.test.ts | 79 +++++++++++++++++++ neode-ui/src/views/discover/curatedApps.ts | 55 +++++++++++-- 7 files changed, 191 insertions(+), 27 deletions(-) create mode 100644 neode-ui/src/views/discover/__tests__/curatedApps.portAuth.test.ts diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index fa75455a..d9e67039 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -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() diff --git a/neode-ui/src/stores/__tests__/appLauncher.test.ts b/neode-ui/src/stores/__tests__/appLauncher.test.ts index 063eca8c..99154e49 100644 --- a/neode-ui/src/stores/__tests__/appLauncher.test.ts +++ b/neode-ui/src/stores/__tests__/appLauncher.test.ts @@ -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) + }) + }) }) diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index 32808eec..72dac056 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -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_' diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index 2269909a..59c95706 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -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 --- diff --git a/neode-ui/src/views/appSession/useNostrBridge.ts b/neode-ui/src/views/appSession/useNostrBridge.ts index 0f8903aa..042d8610 100644 --- a/neode-ui/src/views/appSession/useNostrBridge.ts +++ b/neode-ui/src/views/appSession/useNostrBridge.ts @@ -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 || '*') } } diff --git a/neode-ui/src/views/discover/__tests__/curatedApps.portAuth.test.ts b/neode-ui/src/views/discover/__tests__/curatedApps.portAuth.test.ts new file mode 100644 index 00000000..69248732 --- /dev/null +++ b/neode-ui/src/views/discover/__tests__/curatedApps.portAuth.test.ts @@ -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() + }) +}) diff --git a/neode-ui/src/views/discover/curatedApps.ts b/neode-ui/src/views/discover/curatedApps.ts index caa91003..44b4cd4f 100644 --- a/neode-ui/src/views/discover/curatedApps.ts +++ b/neode-ui/src/views/discover/curatedApps.ts @@ -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 = { + '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