diff --git a/neode-ui/src/stores/__tests__/appLauncher.test.ts b/neode-ui/src/stores/__tests__/appLauncher.test.ts index dcc76dac..3745c74c 100644 --- a/neode-ui/src/stores/__tests__/appLauncher.test.ts +++ b/neode-ui/src/stores/__tests__/appLauncher.test.ts @@ -256,6 +256,46 @@ describe('useAppLauncherStore', () => { ) }) + // An HTTPS connection must never hand the remote browser (or the phone + // webview) a cleartext app URL: same-host app ports are gate-owned and + // serve TLS on the same port. Plain-http pages keep http exactly as before + // — pinned by every test above this one. + it('upgrades same-host app URLs to https on an https page', () => { + Object.defineProperty(window, 'location', { + value: { origin: 'https://192.0.2.10', protocol: 'https:', hostname: '192.0.2.10' }, + writable: true, + configurable: true, + }) + const store = useAppLauncherStore() + + store.open({ url: 'http://192.0.2.10:8082', title: 'Vaultwarden' }) + + expect(store.isOpen).toBe(false) + expect(store.panelAppId).toBe(null) + expect(mockWindowOpen).toHaveBeenCalledWith( + 'https://192.0.2.10:8082', + '_blank', + 'noopener,noreferrer', + ) + }) + + it('never upgrades a different host on an https page', () => { + Object.defineProperty(window, 'location', { + value: { origin: 'https://192.0.2.10', protocol: 'https:', hostname: '192.0.2.10' }, + writable: true, + configurable: true, + }) + const store = useAppLauncherStore() + + store.open({ url: 'http://192.168.1.100:8082', title: 'Vaultwarden' }) + + expect(mockWindowOpen).toHaveBeenCalledWith( + 'http://192.168.1.100:8082', + '_blank', + 'noopener,noreferrer', + ) + }) + it('opens Gitea path URL in new tab', () => { const store = useAppLauncherStore() diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index ba427556..69ca0114 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -4,7 +4,7 @@ import { rpcClient } from '@/api/rpc-client' import { recordAppLaunch } from '@/utils/appUsage' import { requestExternalOpen } from '@/api/remote-relay' import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal' -import { resolveAppUrl } from '@/views/appSession/appSessionConfig' +import { directAppUrl, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig' import { useAppStore } from '@/stores/app' import { resolveAppIcon } from '@/views/apps/appsConfig' import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro' @@ -60,9 +60,6 @@ const NEW_TAB_APP_IDS = new Set([ 'netbird', ]) -// Apps served over HTTPS (self-signed) rather than plain HTTP. -const HTTPS_APP_IDS = new Set(['netbird']) - function mustOpenInNewTab(url: string): boolean { try { const u = new URL(url) @@ -147,33 +144,7 @@ const PORT_TO_APP_ID: Record = { '50002': 'electrumx', } -const APP_ID_TO_PORT: Record = { - 'btcpay-server': '23000', - grafana: '3000', - photoprism: '2342', - homeassistant: '8123', - vaultwarden: '8082', - nextcloud: '8085', - portainer: '9000', - tailscale: '8240', - 'nginx-proxy-manager': '8081', - 'uptime-kuma': '3002', - gitea: '3001', - // Without this, directAppUrl('netbird') returns null and netbird falls - // through to the iframe (and never gets its https URL) — issue #15. - netbird: '8087', -} - -function directAppUrl(appId: string): string | null { - const port = APP_ID_TO_PORT[appId] - if (!port || typeof window === 'undefined') return null - const scheme = HTTPS_APP_IDS.has(appId) ? 'https' : 'http' - return `${scheme}://${window.location.hostname}:${port}` -} - - const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins' - function getApprovedOrigins(): Set { try { const stored = localStorage.getItem(APPROVED_ORIGINS_KEY) @@ -285,18 +256,25 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { let launchUrl = normalizeLaunchUrl(payload.url, titleHintId) const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId - // Apps served over HTTPS (e.g. netbird, which needs a secure context for - // its OIDC dashboard) must be launched over https — a stale http URL hits - // the TLS port and 400s. Upgrade the scheme defensively in every path. - if (resolvedId && HTTPS_APP_IDS.has(resolvedId)) { - try { - const u = new URL(launchUrl, window.location.origin) - if (u.protocol === 'http:') { - u.protocol = 'https:' - launchUrl = u.href - } - } catch { /* leave as-is */ } - } + // Scheme discipline for everything launched on this host. App ports are + // owned by the app gate, which serves TLS on the same port whenever the + // node has a certificate — so on an HTTPS connection every same-host + // app URL must be https: plain http is a silent downgrade at best and + // mixed-content-blocked at worst (remote browsers, the companion + // webview). Apps that are ALWAYS https (netbird's secure-context OIDC + // dashboard) upgrade regardless of the page, and external hosts keep + // their own scheme. + try { + const u = new URL(launchUrl, window.location.origin) + const sameHost = u.hostname === window.location.hostname + const alwaysHttps = !!resolvedId && HTTPS_APP_IDS.has(resolvedId) + const httpsPage = window.location.protocol === 'https:' + if (u.protocol === 'http:' && (alwaysHttps || (httpsPage && sameHost && (resolvedId || mustOpenInNewTab(launchUrl))))) { + // Pure prefix swap — never re-serialize the URL (URL.href would add + // a trailing slash and change the string the caller handed over). + launchUrl = launchUrl.replace(/^http:\/\//i, 'https://') + } + } catch { /* leave as-is */ } if (!isMobileViewport() && payload.openInNewTab) { if (resolvedId) recordAppLaunch(resolvedId) diff --git a/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts b/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts index 54ceb5e1..47242236 100644 --- a/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts +++ b/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { NEW_TAB_APPS, resolveAppUrl } from '../appSessionConfig' +import { NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig' import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig' describe('appSessionConfig', () => { @@ -68,4 +68,51 @@ describe('appSessionConfig', () => { expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083') }) + + // The direct-port launch path (new-tab apps on desktop, the companion's + // native WebView on phones) used to hardcode http:// — so a node reached + // over HTTPS opened Vaultwarden and friends in cleartext. These pin the + // scheme-following contract on both page schemes. + it('builds direct app URLs on the page scheme — https page, https app', () => { + Object.defineProperty(window, 'location', { + value: { hostname: '192.0.2.10', protocol: 'https:' }, + writable: true, + configurable: true, + }) + + expect(directAppUrl('vaultwarden')).toBe('https://192.0.2.10:8082') + expect(directAppUrl('gitea')).toBe('https://192.0.2.10:3001') + expect(directAppUrl('btcpay-server')).toBe('https://192.0.2.10:23000') + }) + + it('keeps plain-http direct app URLs on a plain-http page', () => { + Object.defineProperty(window, 'location', { + value: { hostname: '192.0.2.10', protocol: 'http:' }, + writable: true, + configurable: true, + }) + + expect(directAppUrl('vaultwarden')).toBe('http://192.0.2.10:8082') + }) + + it('always launches secure-context apps over https, on either page scheme', () => { + Object.defineProperty(window, 'location', { + value: { hostname: '192.0.2.10', protocol: 'http:' }, + writable: true, + configurable: true, + }) + + expect(directAppUrl('netbird')).toBe('https://192.0.2.10:8087') + }) + + it('resolves session app URLs on the page scheme too (https page)', () => { + Object.defineProperty(window, 'location', { + value: { hostname: '192.0.2.10', protocol: 'https:' }, + writable: true, + configurable: true, + }) + + expect(resolveAppUrl('mempool')).toBe('https://192.0.2.10:4080') + expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('https://192.0.2.10:18083') + }) }) diff --git a/neode-ui/src/views/appSession/appSessionConfig.ts b/neode-ui/src/views/appSession/appSessionConfig.ts index 422e55ee..f4592c77 100644 --- a/neode-ui/src/views/appSession/appSessionConfig.ts +++ b/neode-ui/src/views/appSession/appSessionConfig.ts @@ -166,6 +166,49 @@ function pageScheme(): string { return p === 'https:' || p === 'http:' ? p : 'http:' } +/** Apps served over HTTPS (self-signed) rather than plain HTTP, regardless of + * the page's scheme. */ +export const HTTPS_APP_IDS = new Set(['netbird']) + +/** App ID -> direct launch port for the paths that bypass the in-app session: + * new-tab apps and the companion's native WebView. Every port here is owned + * by the app gate (manifest `auth: gated`/`open` + `bind: 127.0.0.1`), which + * serves TLS on the same port whenever the node has a certificate. */ +export const DIRECT_APP_PORTS: Record = { + 'btcpay-server': '23000', + grafana: '3000', + photoprism: '2342', + homeassistant: '8123', + vaultwarden: '8082', + nextcloud: '8085', + portainer: '9000', + tailscale: '8240', + 'nginx-proxy-manager': '8081', + 'uptime-kuma': '3002', + gitea: '3001', + // Without this, directAppUrl('netbird') returns null and netbird falls + // through to the iframe (and never gets its https URL) — issue #15. + netbird: '8087', +} + +/** Direct-port launch URL for an app, on the page's scheme. + * + * These are the apps that open OUTSIDE the dashboard's own origin — a new + * browser tab on the desktop, or the companion's in-app WebView on a phone. + * The URL is handed to a context with no dashboard chrome, so it must carry + * the scheme the remote browser actually reached the node on: on an HTTPS + * connection, `http://host:port` is at best a silent downgrade to cleartext + * and at worst blocked outright as mixed content. Every port in + * DIRECT_APP_PORTS is served by the app gate with TLS on the same port + * (see appgate/tls.rs), so following the page scheme is always answerable. + * Plain-HTTP dashboards keep today's behaviour exactly. */ +export function directAppUrl(appId: string): string | null { + const port = DIRECT_APP_PORTS[appId] + if (!port || typeof window === 'undefined') return null + const scheme = HTTPS_APP_IDS.has(appId) || pageScheme() === 'https:' ? 'https' : 'http' + return `${scheme}://${window.location.hostname}:${port}` +} + /** 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()) diff --git a/neode-ui/src/views/apps/appsConfig.ts b/neode-ui/src/views/apps/appsConfig.ts index b1535e42..35e46ec3 100644 --- a/neode-ui/src/views/apps/appsConfig.ts +++ b/neode-ui/src/views/apps/appsConfig.ts @@ -3,7 +3,7 @@ import type { Ref } from 'vue' import { computed } from 'vue' import { PackageState, type PackageDataEntry } from '@/types/api' -import { resolveAppUrl } from '../appSession/appSessionConfig' +import { matchPageScheme, resolveAppUrl } from '../appSession/appSessionConfig' import { isAutoTabApp } from '@/utils/autoTabApps' export type AppsTab = 'apps' | 'websites' | 'services' @@ -299,7 +299,13 @@ export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string { const addr = runtimeLanAddress(pkg) if (!addr || typeof window === 'undefined') return addr - return addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`) + const local = addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`) + // The backend reports runtime URLs as http:// because that is how the app + // binds locally — on an HTTPS connection that is a cleartext downgrade + // (and mixed-content-blocked when opened from the dashboard). The gate + // serves TLS on every app port, so follow the page's scheme, exactly like + // resolveAppUrl() does for the same runtime URLs. + return matchPageScheme(local) } export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string {