From f09ff102eeb6091307acd645b9da9926599b4a02 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 6 Aug 2026 14:43:08 -0400 Subject: [PATCH] fix(ui): app frames follow the dashboard's scheme instead of forcing http MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both schemes now work, and each one works properly: - HTTP dashboard -> http app origin (unchanged; no certificate needed) - HTTPS dashboard -> https app origin (needs the node CA + TLS on the port) The app URL was hardcoded to http://, which on an HTTPS dashboard is mixed content — blocked outright, before the SameSite cookie question the symptom was filed under. It is also what made the two origins schemefully cross-site, so following the page's scheme fixes both causes at once. Backend-reported runtime URLs get the same treatment: the daemon reports http:// because that is how the app binds locally, which is right for the node and wrong for a browser on an HTTPS page. pageScheme() defaults to http when location.protocol is absent (non-browser contexts) — the safe direction, since inventing an https URL for a port that serves no TLS would break a working setup. That default is also why the three existing resolveAppUrl tests, whose fixture stubs location without a protocol, keep passing unmodified rather than being edited to fit. Co-Authored-By: Claude Opus 5 (1M context) --- .../appSession/__tests__/appOrigin.test.ts | 55 +++++++++++++++++++ .../src/views/appSession/appSessionConfig.ts | 45 ++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 neode-ui/src/views/appSession/__tests__/appOrigin.test.ts diff --git a/neode-ui/src/views/appSession/__tests__/appOrigin.test.ts b/neode-ui/src/views/appSession/__tests__/appOrigin.test.ts new file mode 100644 index 00000000..79e7661f --- /dev/null +++ b/neode-ui/src/views/appSession/__tests__/appOrigin.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { appOrigin, matchPageScheme } from '../appSessionConfig' + +// An HTTPS dashboard cannot embed an HTTP app frame — browsers block it as +// mixed content — so the app origin has to follow the page's scheme. Plain-HTTP +// nodes must be completely unaffected, which is what most of these pin. + +function setLocation(protocol: string, hostname: string) { + Object.defineProperty(window, 'location', { + value: { protocol, hostname }, + writable: true, + configurable: true, + }) +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('appOrigin', () => { + it('stays on http for an http dashboard', () => { + setLocation('http:', 'archi-dev-box') + expect(appOrigin(8334)).toBe('http://archi-dev-box:8334') + }) + + it('follows an https dashboard onto the app port', () => { + setLocation('https:', 'archi-dev-box') + expect(appOrigin(8334)).toBe('https://archi-dev-box:8334') + }) + + it('keeps the hostname the user actually typed, not a fixed name', () => { + setLocation('https:', '100.69.68.39') + expect(appOrigin(3000)).toBe('https://100.69.68.39:3000') + }) +}) + +describe('matchPageScheme', () => { + it('leaves backend-reported http URLs alone on an http page', () => { + setLocation('http:', 'node') + expect(matchPageScheme('http://node:8080/app')).toBe('http://node:8080/app') + }) + + it('upgrades a backend-reported http URL on an https page', () => { + setLocation('https:', 'node') + expect(matchPageScheme('http://node:8080/app')).toBe('https://node:8080/app') + }) + + it('does not touch anything but the scheme', () => { + setLocation('https:', 'node') + expect(matchPageScheme('http://node:8080/a/b?c=1#d')).toBe('https://node:8080/a/b?c=1#d') + }) + + it('leaves an already-https URL untouched', () => { + setLocation('https:', 'node') + expect(matchPageScheme('https://node:8080/app')).toBe('https://node:8080/app') + }) +}) diff --git a/neode-ui/src/views/appSession/appSessionConfig.ts b/neode-ui/src/views/appSession/appSessionConfig.ts index 8f614f49..422e55ee 100644 --- a/neode-ui/src/views/appSession/appSessionConfig.ts +++ b/neode-ui/src/views/appSession/appSessionConfig.ts @@ -107,11 +107,15 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: // shell when proxied under a path prefix on some nodes. if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') { if (import.meta.env.DEV) return '/app/bitcoin-ui/' - return 'http://' + window.location.hostname + ':8334' + return appOrigin(8334) } if (runtimeUrl && id !== 'netbird') { let base = runtimeUrl.replace(/localhost/i, window.location.hostname) + // The backend reports runtime URLs as http:// because that is how the app + // binds locally. Sent to a browser on an HTTPS dashboard that is mixed + // content and the frame is blocked outright, so follow the page instead. + base = matchPageScheme(base) if (routeQueryPath) base += routeQueryPath return base } @@ -120,11 +124,48 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: const port = APP_PORTS[id] if (!port) return '' - let base = 'http://' + window.location.hostname + ':' + String(port) + let base = appOrigin(port) if (routeQueryPath) base += routeQueryPath return base } +/** + * An app's origin on this host, on the SAME scheme as the page. + * + * An HTTPS dashboard cannot embed an HTTP frame at all — browsers block it as + * mixed content before any cookie question arises — and it is also what makes + * the two origins schemefully cross-site, so the session cookie is withheld. + * Following the page's scheme fixes both at once and keeps plain HTTP working + * exactly as before on nodes that serve the dashboard over HTTP. + * + * On HTTPS this requires the app port to actually serve TLS with a certificate + * the browser trusts — see scripts/setup-node-ca.sh and Settings → System → + * Node certificate. A certificate warning cannot be accepted inside an iframe, + * so an untrusted app port renders nothing rather than prompting. + */ +export function appOrigin(port: number): string { + return `${pageScheme()}//${window.location.hostname}:${port}` +} + +/** Rewrite a URL's scheme to the page's, leaving everything else alone. */ +export function matchPageScheme(url: string): string { + if (pageScheme() !== 'https:') return url + return url.replace(/^http:\/\//i, 'https://') +} + +/** + * The page's scheme, defaulting to http. + * + * A real browser always has location.protocol; this defends the non-browser + * cases (tests, SSR-ish contexts) where it can be absent. Defaulting to http + * is the safe direction — it preserves today's behaviour rather than inventing + * an https URL for a port that may not serve TLS. + */ +function pageScheme(): string { + const p = window.location?.protocol + return p === 'https:' || p === 'http:' ? p : 'http:' +} + /** Resolve a human-readable title for an app */ export function resolveAppTitle(id: string): string { return APP_TITLES[id] || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())