fix(ui): launch apps on the page's scheme over HTTPS

New-tab apps and the companion WebView got hardcoded http:// URLs, so a
node reached over HTTPS opened Vaultwarden, BTCPay, Grafana et al in
cleartext. Every app port is gate-owned and serves TLS on the same port
(appgate/tls.rs), so directAppUrl(), the legacy open() path, and
resolveRuntimeLaunchUrl() now follow the page's scheme. HTTP pages (the
kiosk, LAN) are unchanged; netbird keeps its unconditional https.
This commit is contained in:
archipelago
2026-08-31 17:08:26 -04:00
parent 3089624969
commit 7c0a492c43
5 changed files with 159 additions and 45 deletions
@@ -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')
})
})
@@ -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<string, string> = {
'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())
+8 -2
View File
@@ -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 {