fix(ui): app frames follow the dashboard's scheme instead of forcing http
Demo images / Build & push demo images (push) Failing after 2m10s
Demo images / Build & push demo images (push) Failing after 2m10s
Both schemes now work, and each one works properly: - HTTP dashboard -> http app origin (unchanged; no certificate needed) - HTTPS dashboard -> https app origin (needs the node CA + TLS on the port) The app URL was hardcoded to http://, which on an HTTPS dashboard is mixed content — blocked outright, before the SameSite cookie question the symptom was filed under. It is also what made the two origins schemefully cross-site, so following the page's scheme fixes both causes at once. Backend-reported runtime URLs get the same treatment: the daemon reports http:// because that is how the app binds locally, which is right for the node and wrong for a browser on an HTTPS page. pageScheme() defaults to http when location.protocol is absent (non-browser contexts) — the safe direction, since inventing an https URL for a port that serves no TLS would break a working setup. That default is also why the three existing resolveAppUrl tests, whose fixture stubs location without a protocol, keep passing unmodified rather than being edited to fit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
aab74127f5
commit
f09ff102ee
@@ -0,0 +1,55 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { appOrigin, matchPageScheme } from '../appSessionConfig'
|
||||
|
||||
// An HTTPS dashboard cannot embed an HTTP app frame — browsers block it as
|
||||
// mixed content — so the app origin has to follow the page's scheme. Plain-HTTP
|
||||
// nodes must be completely unaffected, which is what most of these pin.
|
||||
|
||||
function setLocation(protocol: string, hostname: string) {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol, hostname },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('appOrigin', () => {
|
||||
it('stays on http for an http dashboard', () => {
|
||||
setLocation('http:', 'archi-dev-box')
|
||||
expect(appOrigin(8334)).toBe('http://archi-dev-box:8334')
|
||||
})
|
||||
|
||||
it('follows an https dashboard onto the app port', () => {
|
||||
setLocation('https:', 'archi-dev-box')
|
||||
expect(appOrigin(8334)).toBe('https://archi-dev-box:8334')
|
||||
})
|
||||
|
||||
it('keeps the hostname the user actually typed, not a fixed name', () => {
|
||||
setLocation('https:', '100.69.68.39')
|
||||
expect(appOrigin(3000)).toBe('https://100.69.68.39:3000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchPageScheme', () => {
|
||||
it('leaves backend-reported http URLs alone on an http page', () => {
|
||||
setLocation('http:', 'node')
|
||||
expect(matchPageScheme('http://node:8080/app')).toBe('http://node:8080/app')
|
||||
})
|
||||
|
||||
it('upgrades a backend-reported http URL on an https page', () => {
|
||||
setLocation('https:', 'node')
|
||||
expect(matchPageScheme('http://node:8080/app')).toBe('https://node:8080/app')
|
||||
})
|
||||
|
||||
it('does not touch anything but the scheme', () => {
|
||||
setLocation('https:', 'node')
|
||||
expect(matchPageScheme('http://node:8080/a/b?c=1#d')).toBe('https://node:8080/a/b?c=1#d')
|
||||
})
|
||||
|
||||
it('leaves an already-https URL untouched', () => {
|
||||
setLocation('https:', 'node')
|
||||
expect(matchPageScheme('https://node:8080/app')).toBe('https://node:8080/app')
|
||||
})
|
||||
})
|
||||
@@ -107,11 +107,15 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
|
||||
// shell when proxied under a path prefix on some nodes.
|
||||
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
|
||||
if (import.meta.env.DEV) return '/app/bitcoin-ui/'
|
||||
return 'http://' + window.location.hostname + ':8334'
|
||||
return appOrigin(8334)
|
||||
}
|
||||
|
||||
if (runtimeUrl && id !== 'netbird') {
|
||||
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
|
||||
// The backend reports runtime URLs as http:// because that is how the app
|
||||
// binds locally. Sent to a browser on an HTTPS dashboard that is mixed
|
||||
// content and the frame is blocked outright, so follow the page instead.
|
||||
base = matchPageScheme(base)
|
||||
if (routeQueryPath) base += routeQueryPath
|
||||
return base
|
||||
}
|
||||
@@ -120,11 +124,48 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
|
||||
const port = APP_PORTS[id]
|
||||
if (!port) return ''
|
||||
|
||||
let base = 'http://' + window.location.hostname + ':' + String(port)
|
||||
let base = appOrigin(port)
|
||||
if (routeQueryPath) base += routeQueryPath
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* An app's origin on this host, on the SAME scheme as the page.
|
||||
*
|
||||
* An HTTPS dashboard cannot embed an HTTP frame at all — browsers block it as
|
||||
* mixed content before any cookie question arises — and it is also what makes
|
||||
* the two origins schemefully cross-site, so the session cookie is withheld.
|
||||
* Following the page's scheme fixes both at once and keeps plain HTTP working
|
||||
* exactly as before on nodes that serve the dashboard over HTTP.
|
||||
*
|
||||
* On HTTPS this requires the app port to actually serve TLS with a certificate
|
||||
* the browser trusts — see scripts/setup-node-ca.sh and Settings → System →
|
||||
* Node certificate. A certificate warning cannot be accepted inside an iframe,
|
||||
* so an untrusted app port renders nothing rather than prompting.
|
||||
*/
|
||||
export function appOrigin(port: number): string {
|
||||
return `${pageScheme()}//${window.location.hostname}:${port}`
|
||||
}
|
||||
|
||||
/** Rewrite a URL's scheme to the page's, leaving everything else alone. */
|
||||
export function matchPageScheme(url: string): string {
|
||||
if (pageScheme() !== 'https:') return url
|
||||
return url.replace(/^http:\/\//i, 'https://')
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's scheme, defaulting to http.
|
||||
*
|
||||
* A real browser always has location.protocol; this defends the non-browser
|
||||
* cases (tests, SSR-ish contexts) where it can be absent. Defaulting to http
|
||||
* is the safe direction — it preserves today's behaviour rather than inventing
|
||||
* an https URL for a port that may not serve TLS.
|
||||
*/
|
||||
function pageScheme(): string {
|
||||
const p = window.location?.protocol
|
||||
return p === 'https:' || p === 'http:' ? p : 'http:'
|
||||
}
|
||||
|
||||
/** 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())
|
||||
|
||||
Reference in New Issue
Block a user