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:
archipelago
2026-09-01 10:29:05 -04:00
parent e382e679ae
commit 3347b8b8b9
7 changed files with 191 additions and 27 deletions
+22 -12
View File
@@ -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_'