Normalize only loopback authorities in embedded app runtime URLs

This commit is contained in:
archipelago
2026-10-06 15:05:17 -04:00
parent c3bfbe8519
commit 88d473f6e1
3 changed files with 40 additions and 1 deletions
@@ -107,6 +107,22 @@ describe('appSessionConfig', () => {
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:8083')).toBe('http://192.0.2.10:8083')
})
it('keeps loopback runtime addresses on the dashboard host for embedded apps', () => {
stubLocation({ hostname: 'node.example', protocol: 'https:' })
for (const host of ['localhost', '127.0.0.1', '[::1]']) {
expect(resolveAppUrl('filebrowser', undefined, `http://${host}:8083/files?view=grid#recent`))
.toBe('https://node.example:8083/files?view=grid#recent')
}
})
it('does not replace localhost text inside an external hostname or path', () => {
stubLocation({ hostname: 'node.example', protocol: 'http:' })
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost.example:8083/localhost'))
.toBe('http://localhost.example:8083/localhost')
expect(resolveAppUrl('filebrowser', undefined, 'http://media.example:8083/localhost'))
.toBe('http://media.example:8083/localhost')
})
// 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. It must follow
@@ -172,7 +172,12 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
}
if (runtimeUrl && id !== 'netbird') {
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
// Only rewrite a loopback authority. A substring replacement also changes
// external hostnames and paths, while leaving 127.0.0.1 pointed at the viewer.
let base = runtimeUrl.replace(
/^(https?:\/\/)(localhost|127\.0\.0\.1|\[::1\])(?=[:/?#]|$)/i,
(_match, scheme: string) => `${scheme}${window.location.hostname}`,
)
// The backend reports runtime URLs as http:// because that is how the app
// binds locally. On an HTTPS dashboard that is mixed content and the
// frame is blocked outright — but ONLY upgrade when the gate fronts the