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
@@ -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()
+20 -42
View File
@@ -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<string, string> = {
'50002': 'electrumx',
}
const APP_ID_TO_PORT: 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',
}
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<string> {
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)