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:
@@ -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', () => {
|
it('opens Gitea path URL in new tab', () => {
|
||||||
const store = useAppLauncherStore()
|
const store = useAppLauncherStore()
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { rpcClient } from '@/api/rpc-client'
|
|||||||
import { recordAppLaunch } from '@/utils/appUsage'
|
import { recordAppLaunch } from '@/utils/appUsage'
|
||||||
import { requestExternalOpen } from '@/api/remote-relay'
|
import { requestExternalOpen } from '@/api/remote-relay'
|
||||||
import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal'
|
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 { useAppStore } from '@/stores/app'
|
||||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||||
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
||||||
@@ -60,9 +60,6 @@ const NEW_TAB_APP_IDS = new Set([
|
|||||||
'netbird',
|
'netbird',
|
||||||
])
|
])
|
||||||
|
|
||||||
// Apps served over HTTPS (self-signed) rather than plain HTTP.
|
|
||||||
const HTTPS_APP_IDS = new Set(['netbird'])
|
|
||||||
|
|
||||||
function mustOpenInNewTab(url: string): boolean {
|
function mustOpenInNewTab(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url)
|
const u = new URL(url)
|
||||||
@@ -147,33 +144,7 @@ const PORT_TO_APP_ID: Record<string, string> = {
|
|||||||
'50002': 'electrumx',
|
'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'
|
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
|
||||||
|
|
||||||
function getApprovedOrigins(): Set<string> {
|
function getApprovedOrigins(): Set<string> {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(APPROVED_ORIGINS_KEY)
|
const stored = localStorage.getItem(APPROVED_ORIGINS_KEY)
|
||||||
@@ -285,18 +256,25 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
||||||
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
||||||
|
|
||||||
// Apps served over HTTPS (e.g. netbird, which needs a secure context for
|
// Scheme discipline for everything launched on this host. App ports are
|
||||||
// its OIDC dashboard) must be launched over https — a stale http URL hits
|
// owned by the app gate, which serves TLS on the same port whenever the
|
||||||
// the TLS port and 400s. Upgrade the scheme defensively in every path.
|
// node has a certificate — so on an HTTPS connection every same-host
|
||||||
if (resolvedId && HTTPS_APP_IDS.has(resolvedId)) {
|
// 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 {
|
try {
|
||||||
const u = new URL(launchUrl, window.location.origin)
|
const u = new URL(launchUrl, window.location.origin)
|
||||||
if (u.protocol === 'http:') {
|
const sameHost = u.hostname === window.location.hostname
|
||||||
u.protocol = 'https:'
|
const alwaysHttps = !!resolvedId && HTTPS_APP_IDS.has(resolvedId)
|
||||||
launchUrl = u.href
|
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 */ }
|
} catch { /* leave as-is */ }
|
||||||
}
|
|
||||||
|
|
||||||
if (!isMobileViewport() && payload.openInNewTab) {
|
if (!isMobileViewport() && payload.openInNewTab) {
|
||||||
if (resolvedId) recordAppLaunch(resolvedId)
|
if (resolvedId) recordAppLaunch(resolvedId)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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'
|
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
|
||||||
|
|
||||||
describe('appSessionConfig', () => {
|
describe('appSessionConfig', () => {
|
||||||
@@ -68,4 +68,51 @@ describe('appSessionConfig', () => {
|
|||||||
|
|
||||||
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083')
|
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:'
|
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 */
|
/** Resolve a human-readable title for an app */
|
||||||
export function resolveAppTitle(id: string): string {
|
export function resolveAppTitle(id: string): string {
|
||||||
return APP_TITLES[id] || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
return APP_TITLES[id] || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||||
import { resolveAppUrl } from '../appSession/appSessionConfig'
|
import { matchPageScheme, resolveAppUrl } from '../appSession/appSessionConfig'
|
||||||
import { isAutoTabApp } from '@/utils/autoTabApps'
|
import { isAutoTabApp } from '@/utils/autoTabApps'
|
||||||
|
|
||||||
export type AppsTab = 'apps' | 'websites' | 'services'
|
export type AppsTab = 'apps' | 'websites' | 'services'
|
||||||
@@ -299,7 +299,13 @@ export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null):
|
|||||||
export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
|
export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
|
||||||
const addr = runtimeLanAddress(pkg)
|
const addr = runtimeLanAddress(pkg)
|
||||||
if (!addr || typeof window === 'undefined') return addr
|
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 {
|
export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user