feat(desktop): remote-host mode + OS-store client-cert mTLS (D-1/D-2)

- remote-hosts.ts + DesktopPrefs (remoteHosts/selectedHostId, immutable, back-compat) + tray host-picker.
- will-navigate lock is now dynamic to the selected remote origin, fails closed on null (no open-redirect).
- select-client-certificate handler: event.preventDefault() first, pick by issuer CN, non-silent
  missing-cert dialog + cert-less callback -> clean nginx rejection (Chromium sources certs from OS store).
Verified: npm run typecheck exit 0; pure-helper assertions pass.
This commit is contained in:
Yaojia Wang
2026-07-07 09:42:12 +02:00
parent e38e6d1689
commit bb0949553c
6 changed files with 604 additions and 33 deletions

View File

@@ -1,12 +1,19 @@
/**
* desktop/src/window.ts — creates the single BrowserWindow that hosts the
* unchanged web frontend, loaded from the embedded localhost server.
* unchanged web frontend, loaded from either the embedded localhost server or a
* selected remote tunnel host (D-1, PLAN_NATIVE_TUNNEL / C-Desktop).
*
* Hardening (DESKTOP_PLAN §8 / TECH_DOC §7): contextIsolation on, nodeIntegration
* off, sandbox on, preload restricted to a minimal contextBridge. Because the
* only page ever loaded is the trusted embedded http://127.0.0.1:<port> origin,
* we deny every new-window request and block navigation to any foreign origin
* a defence-in-depth guard against a hijacked page trying to escape localhost.
* off, sandbox on, preload restricted to a minimal contextBridge. We deny every
* new-window request and block navigation to any foreign origin — a defence-in-
* depth guard against a hijacked page trying to escape the active origin.
*
* D-1 note: the origin lock is DYNAMIC. The window may be pointed (by main.ts,
* via a programmatic loadURL — which does NOT fire will-navigate) at exactly one
* host at a time: the embedded `http://127.0.0.1:<port>` origin OR the selected
* remote `https://<name>.terminal.yaojia.wang` origin. `getAllowedOrigin` returns
* whichever is active NOW, so renderer-initiated navigation stays locked to that
* single origin and everything else is still blocked.
*/
import { BrowserWindow } from 'electron'
@@ -15,7 +22,7 @@ const WINDOW_HEIGHT = 720
const BACKGROUND_COLOR = '#0e0f13'
/** Parse the origin of a URL, returning null for anything malformed. */
function originOf(url: string): string | null {
export function originOf(url: string): string | null {
try {
return new URL(url).origin
} catch {
@@ -23,7 +30,16 @@ function originOf(url: string): string | null {
}
}
export function createMainWindow(url: string, preloadPath: string): BrowserWindow {
/**
* Create the main window. `url` is the initial page to load; `getAllowedOrigin`
* is queried live on every navigation attempt and must return the origin the
* window is currently allowed on (or null to allow nothing / fail closed).
*/
export function createMainWindow(
url: string,
preloadPath: string,
getAllowedOrigin: () => string | null,
): BrowserWindow {
const win = new BrowserWindow({
width: WINDOW_WIDTH,
height: WINDOW_HEIGHT,
@@ -36,15 +52,14 @@ export function createMainWindow(url: string, preloadPath: string): BrowserWindo
},
})
const allowedOrigin = originOf(url)
// Never spawn child windows; the frontend has no legitimate reason to.
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
// Block navigation away from the embedded localhost origin.
// Block navigation away from the currently-active (local or remote) origin.
win.webContents.on('will-navigate', (event, targetUrl) => {
if (allowedOrigin === null) return
if (originOf(targetUrl) !== allowedOrigin) {
const allowedOrigin = getAllowedOrigin()
// Fail closed: if we can't determine the active origin, allow nothing.
if (allowedOrigin === null || originOf(targetUrl) !== allowedOrigin) {
event.preventDefault()
}
})