diff --git a/SECURITY.md b/SECURITY.md index 0bee56d3..dcb11d2a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,3 +3,21 @@ ## Reporting a Vulnerability Please report any vulnerabilities to [GitHub Security](https://github.com/Termix-SSH/Termix/security/advisories). + +## External secret storage + +By default, a single-container installation generates its keys in the Termix +data directory for ease of recovery. Production deployments that keep backups +or database files outside a trusted encrypted volume should set +`TERMIX_REQUIRE_EXTERNAL_SECRETS=true` and provide all four keys through a +secret manager: + +- `JWT_SECRET` (at least 64 characters) +- `DATABASE_KEY` (64 hexadecimal characters) +- `ENCRYPTION_KEY` (64 hexadecimal characters) +- `INTERNAL_AUTH_TOKEN` (at least 32 characters) + +Each value can instead be mounted as a Docker or Kubernetes secret and supplied +with its corresponding `_FILE` variable, such as `ENCRYPTION_KEY_FILE`. +Hardened mode fails closed instead of writing a replacement key beside the +encrypted database. diff --git a/charts/termix/values.yaml b/charts/termix/values.yaml index 043499c9..55cf7761 100644 --- a/charts/termix/values.yaml +++ b/charts/termix/values.yaml @@ -75,6 +75,7 @@ env: PORT: "8080" DATA_DIR: /app/data NODE_ENV: production + TERMIX_REQUIRE_EXTERNAL_SECRETS: "false" GUACD_RECORDING_PATH: /termix-data/session_recordings/guacamole GUACD_RECORDING_BACKEND_PATH: /app/data/session_recordings/guacamole @@ -86,6 +87,9 @@ secrets: name: "" data: JWT_SECRET: "" + DATABASE_KEY: "" + ENCRYPTION_KEY: "" + INTERNAL_AUTH_TOKEN: "" DATABASE_URL: "" GUACAMOLE_ENCRYPTION_KEY: "" diff --git a/docker/Dockerfile b/docker/Dockerfile index cb9e0ee3..4ca2eeec 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -106,7 +106,7 @@ COPY --chown=node:node drizzle ./drizzle VOLUME ["/app/data"] -EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006 30007 30008 30009 30010 30011 30012 +EXPOSE ${PORT} HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD wget -q -O /dev/null http://localhost:30001/health || exit 1 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 033c6d8e..b0d2a850 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -12,6 +12,10 @@ services: GUACD_HOST: "guacd" GUACD_TUNNEL_HOST: "termix" GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole" + # Hardened deployments can require keys from environment variables or + # Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE, + # ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE. + # TERMIX_REQUIRE_EXTERNAL_SECRETS: "true" # Trusted reverse-proxy authentication is disabled by default. When # enabled, do not expose this container directly to untrusted clients. # TRUSTED_PROXY_AUTH_ENABLED: "true" diff --git a/electron/main.cjs b/electron/main.cjs index bd23c255..94a33678 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -21,7 +21,7 @@ const net = require("net"); const tls = require("tls"); const zlib = require("zlib"); const crypto = require("crypto"); -const { URL } = require("url"); +const { URL, pathToFileURL } = require("url"); const { fork, spawn } = require("child_process"); const pty = require("node-pty"); const WebSocket = require("ws"); @@ -1193,11 +1193,12 @@ function createWindow() { webPreferences: { nodeIntegration: false, contextIsolation: true, - webSecurity: false, + sandbox: true, + webSecurity: true, preload: path.join(__dirname, "preload.js"), partition: termixSessionPartition, - allowRunningInsecureContent: true, - webviewTag: true, + allowRunningInsecureContent: false, + webviewTag: false, offscreen: false, }, show: true, @@ -1377,6 +1378,13 @@ function createWindow() { } return { action: "deny" }; }); + + mainWindow.webContents.on("will-navigate", (event, url) => { + const allowedUrl = isDev + ? url.startsWith("http://localhost:5173/") + : url === pathToFileURL(path.join(appRoot, "dist", "index.html")).href; + if (!allowedUrl) event.preventDefault(); + }); } ipcMain.handle("get-app-version", () => { diff --git a/electron/preload.js b/electron/preload.js index b045cf65..d5c25574 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -1,5 +1,28 @@ const { contextBridge, ipcRenderer } = require("electron"); +const ALLOWED_INVOKE_CHANNELS = new Set([ + "check-electron-update", + "clear-remote-sync-config", + "get-desktop-settings", + "get-legacy-server-config", + "get-remote-sync-config", + "get-remote-sync-jwt", + "get-remote-sync-status", + "get-remote-sync-user-info", + "remote-sync-now", + "save-desktop-settings", + "save-remote-sync-config", + "save-remote-sync-jwt", + "test-server-connection", +]); + +function invokeAllowed(channel, ...args) { + if (!ALLOWED_INVOKE_CHANNELS.has(channel)) { + return Promise.reject(new Error(`IPC channel is not allowed: ${channel}`)); + } + return ipcRenderer.invoke(channel, ...args); +} + contextBridge.exposeInMainWorld("electronAPI", { getAppVersion: () => ipcRenderer.invoke("get-app-version"), getPlatform: () => ipcRenderer.invoke("get-platform"), @@ -103,7 +126,7 @@ contextBridge.exposeInMainWorld("electronAPI", { return () => ipcRenderer.removeListener(channel, listener); }, - invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), + invoke: invokeAllowed, }); contextBridge.exposeInMainWorld("electronClipboard", { diff --git a/scripts/electron-security-boundary.test.ts b/scripts/electron-security-boundary.test.ts new file mode 100644 index 00000000..98066fa9 --- /dev/null +++ b/scripts/electron-security-boundary.test.ts @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const main = readFileSync("electron/main.cjs", "utf8"); +const preload = readFileSync("electron/preload.js", "utf8"); + +describe("Electron security boundary", () => { + it("keeps the renderer sandbox and browser security enabled", () => { + expect(main).toContain("sandbox: true"); + expect(main).toContain("webSecurity: true"); + expect(main).toContain("allowRunningInsecureContent: false"); + expect(main).toContain("webviewTag: false"); + }); + + it("does not expose an unrestricted IPC invoke primitive", () => { + expect(preload).toContain("invoke: invokeAllowed"); + expect(preload).not.toContain( + "invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args)", + ); + }); +}); diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index f8bd58e7..4038c87a 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -2034,7 +2034,7 @@ httpServer.on("error", (err: NodeJS.ErrnoException) => { }); export const serverReady = new Promise((resolve) => { - httpServer.listen(HTTP_PORT, async () => { + httpServer.listen(HTTP_PORT, "127.0.0.1", async () => { if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } @@ -2083,7 +2083,7 @@ if ( }); }); - httpsServer.listen(sslConfig.port, () => { + httpsServer.listen(sslConfig.port, "127.0.0.1", () => { databaseLogger.success( `Backend is now also listening for HTTPS directly`, { diff --git a/src/backend/hosts/docker/console.ts b/src/backend/hosts/docker/console.ts index d1088e6f..334a3b20 100644 --- a/src/backend/hosts/docker/console.ts +++ b/src/backend/hosts/docker/console.ts @@ -19,6 +19,7 @@ import { HOST_ADDRESS_MISMATCH_MESSAGE, HOST_NOT_ON_THIS_SERVER_MESSAGE, } from "../terminal/host-identity.js"; +import { extractWebSocketToken } from "../../utils/ws-auth.js"; const sshLogger = systemLogger; @@ -35,7 +36,7 @@ interface SSHSession { const activeSessions = new Map(); const wss = new WebSocketServer({ - host: "0.0.0.0", + host: "127.0.0.1", port: 30009, }); @@ -285,26 +286,7 @@ async function createJumpHostChain( } wss.on("connection", async (ws: WebSocket, req) => { - let token: string | undefined; - - const cookieHeader = req.headers.cookie; - if (cookieHeader) { - const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/); - if (match) token = decodeURIComponent(match[1]); - } - - if (!token) { - const authHeader = req.headers.authorization; - if (authHeader?.startsWith("Bearer ")) { - token = authHeader.slice("Bearer ".length); - } - } - - if (!token) { - const urlObj = new URL(req.url || "", "http://localhost"); - const qp = urlObj.searchParams.get("token"); - if (qp) token = qp; - } + const token = extractWebSocketToken(req); if (!token) { ws.close(1008, "Authentication required"); diff --git a/src/backend/hosts/docker/index.ts b/src/backend/hosts/docker/index.ts index bacf4831..2e2b9139 100644 --- a/src/backend/hosts/docker/index.ts +++ b/src/backend/hosts/docker/index.ts @@ -47,7 +47,7 @@ registerDockerContainerRoutes(app, { const PORT = 30007; -app.listen(PORT, async () => { +app.listen(PORT, "127.0.0.1", async () => { try { await authManager.initialize(); } catch (err) { diff --git a/src/backend/hosts/file-manager/index.ts b/src/backend/hosts/file-manager/index.ts index 00f6ddd0..f38c011f 100644 --- a/src/backend/hosts/file-manager/index.ts +++ b/src/backend/hosts/file-manager/index.ts @@ -3128,7 +3128,7 @@ process.on("SIGTERM", () => { const PORT = 30004; try { - const server = app.listen(PORT, async () => { + const server = app.listen(PORT, "127.0.0.1", async () => { try { await authManager.initialize(); } catch (err) { diff --git a/src/backend/hosts/guacamole/guacamole-server.ts b/src/backend/hosts/guacamole/guacamole-server.ts index 25e3ad3e..d109dcfd 100644 --- a/src/backend/hosts/guacamole/guacamole-server.ts +++ b/src/backend/hosts/guacamole/guacamole-server.ts @@ -140,6 +140,7 @@ async function persistGuacamoleRecording( } const websocketOptions = { + host: "127.0.0.1", port: GUAC_WS_PORT, }; diff --git a/src/backend/hosts/metrics/index.ts b/src/backend/hosts/metrics/index.ts index 26320a1b..4229be4c 100644 --- a/src/backend/hosts/metrics/index.ts +++ b/src/backend/hosts/metrics/index.ts @@ -3071,7 +3071,7 @@ process.on("SIGTERM", () => { }); const PORT = 30005; -app.listen(PORT, async () => { +app.listen(PORT, "127.0.0.1", async () => { try { await authManager.initialize(); } catch (err) { diff --git a/src/backend/hosts/serial.ts b/src/backend/hosts/serial.ts index 9030d2f6..af4da05b 100644 --- a/src/backend/hosts/serial.ts +++ b/src/backend/hosts/serial.ts @@ -5,6 +5,7 @@ import { AuthManager } from "../utils/auth-manager.js"; import { DataCrypto } from "../utils/data-crypto.js"; import { sshLogger } from "../utils/logger.js"; import { parseWsMessage } from "../utils/ws-message.js"; +import { extractWebSocketToken } from "../utils/ws-auth.js"; interface SerialConnectData { path: string; @@ -16,7 +17,7 @@ interface SerialConnectData { const authManager = AuthManager.getInstance(); -const wss = new WebSocketServer({ port: 30011 }); +const wss = new WebSocketServer({ host: "127.0.0.1", port: 30011 }); wss.on("error", (error) => { sshLogger.error("Serial WebSocket server error", error, { @@ -28,26 +29,7 @@ wss.on("connection", async (ws: WebSocket, req) => { let userId: string | undefined; try { - let token: string | undefined; - - const cookieHeader = req.headers.cookie; - if (cookieHeader) { - const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/); - if (match) token = decodeURIComponent(match[1]); - } - - if (!token) { - const authHeader = req.headers.authorization; - if (authHeader?.startsWith("Bearer ")) { - token = authHeader.slice("Bearer ".length); - } - } - - if (!token) { - const urlObj = new URL(req.url || "", "http://localhost"); - const qp = urlObj.searchParams.get("token"); - if (qp) token = qp; - } + const token = extractWebSocketToken(req); if (!token) { ws.close(1008, "Authentication required"); diff --git a/src/backend/hosts/terminal/index.ts b/src/backend/hosts/terminal/index.ts index 7ab60c67..a6cbef64 100644 --- a/src/backend/hosts/terminal/index.ts +++ b/src/backend/hosts/terminal/index.ts @@ -62,6 +62,7 @@ import { HOST_NOT_ON_THIS_SERVER_MESSAGE, resolveServerJumpHosts, } from "./host-identity.js"; +import { extractWebSocketToken } from "../../utils/ws-auth.js"; interface ConnectToHostData { cols: number; @@ -128,6 +129,7 @@ const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000; const userConnections = new Map>(); const wss = new WebSocketServer({ + host: "127.0.0.1", port: 30002, }); @@ -299,25 +301,7 @@ wss.on("connection", async (ws: WebSocket, req) => { } try { - let token: string | undefined; - - const cookieHeader = req.headers.cookie; - if (cookieHeader) { - const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/); - if (match) token = decodeURIComponent(match[1]); - } - - if (!token) { - const authHeader = req.headers.authorization; - if (authHeader?.startsWith("Bearer ")) { - token = authHeader.slice("Bearer ".length); - } - } - - if (!token) { - const qp = urlObj.searchParams.get("token"); - if (qp) token = qp; - } + const token = extractWebSocketToken(req); if (!token) { ws.close(1008, "Authentication required"); diff --git a/src/backend/hosts/tmux/index.ts b/src/backend/hosts/tmux/index.ts index ffa6b70d..e355a15d 100644 --- a/src/backend/hosts/tmux/index.ts +++ b/src/backend/hosts/tmux/index.ts @@ -923,4 +923,4 @@ app.put("/tmux_monitor/:hostId/tags", async (req, res) => { }); const PORT = 30010; -app.listen(PORT, () => {}); +app.listen(PORT, "127.0.0.1", () => {}); diff --git a/src/backend/hosts/tunnel/c2s-relay-utils.ts b/src/backend/hosts/tunnel/c2s-relay-utils.ts index 459c5823..21a34506 100644 --- a/src/backend/hosts/tunnel/c2s-relay-utils.ts +++ b/src/backend/hosts/tunnel/c2s-relay-utils.ts @@ -2,24 +2,14 @@ import type { IncomingMessage } from "http"; import type { Duplex } from "stream"; import type { ClientChannel } from "ssh2"; import type { WebSocket } from "ws"; +import { extractWebSocketToken } from "../../utils/ws-auth.js"; const C2S_WS_HIGH_WATERMARK = 1024 * 1024; const C2S_WS_LOW_WATERMARK = 256 * 1024; const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024; export function extractRequestToken(req: IncomingMessage): string | undefined { - const cookieHeader = req.headers.cookie; - if (cookieHeader) { - const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/); - if (match) return decodeURIComponent(match[1]); - } - - const authHeader = req.headers.authorization; - if (authHeader?.startsWith("Bearer ")) { - return authHeader.slice("Bearer ".length); - } - - return undefined; + return extractWebSocketToken(req); } export function sendC2SError(ws: WebSocket, message: string): void { diff --git a/src/backend/hosts/tunnel/index.ts b/src/backend/hosts/tunnel/index.ts index 5d44eef3..6e4c9001 100644 --- a/src/backend/hosts/tunnel/index.ts +++ b/src/backend/hosts/tunnel/index.ts @@ -100,7 +100,7 @@ c2sRelayWss.on("connection", (ws, req) => { }); }); -server.listen(PORT, () => { +server.listen(PORT, "127.0.0.1", () => { setTimeout(() => { initializeAutoStartTunnels(); }, 2000); diff --git a/src/backend/services/dashboard.ts b/src/backend/services/dashboard.ts index 2810e3ee..f0381e48 100644 --- a/src/backend/services/dashboard.ts +++ b/src/backend/services/dashboard.ts @@ -318,7 +318,7 @@ app.delete("/activity/reset", async (req, res) => { app.use("/service-links", dashboardServiceLinksRouter); const PORT = 30006; -app.listen(PORT, async () => { +app.listen(PORT, "127.0.0.1", async () => { try { await authManager.initialize(); } catch (err) { diff --git a/src/backend/tests/utils/ws-auth.test.ts b/src/backend/tests/utils/ws-auth.test.ts new file mode 100644 index 00000000..b19cb8ae --- /dev/null +++ b/src/backend/tests/utils/ws-auth.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import type { IncomingMessage } from "http"; +import { extractWebSocketToken } from "../../utils/ws-auth.js"; + +function request(headers: Record): IncomingMessage { + return { headers } as IncomingMessage; +} + +describe("extractWebSocketToken", () => { + it("reads JWTs from the WebSocket subprotocol without using the URL", () => { + expect( + extractWebSocketToken( + request({ "sec-websocket-protocol": "termix.jwt.header.payload.sig" }), + ), + ).toBe("header.payload.sig"); + }); + + it("prefers the HttpOnly cookie over renderer-provided protocols", () => { + expect( + extractWebSocketToken( + request({ + cookie: "jwt=cookie-token", + "sec-websocket-protocol": "termix.jwt.protocol-token", + }), + ), + ).toBe("cookie-token"); + }); +}); diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index ff3b6c3f..1f3a7f7f 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -12,6 +12,35 @@ class SystemCrypto { private constructor() {} + private async readExternalSecret( + name: string, + minimumLength: number, + ): Promise { + const direct = process.env[name]?.trim(); + if (direct && direct.length >= minimumLength) return direct; + + const secretFile = process.env[`${name}_FILE`]?.trim(); + if (!secretFile) return null; + const value = (await fs.readFile(secretFile, "utf8")).trim(); + if (value.length < minimumLength) { + throw new Error(`${name}_FILE contains a secret that is too short`); + } + return value; + } + + private requireExternalSecret(name: string): never { + throw new Error( + `${name} must be supplied through ${name} or ${name}_FILE when TERMIX_REQUIRE_EXTERNAL_SECRETS=true`, + ); + } + + private parseExternalHexKey(name: string, value: string): Buffer { + if (!/^[0-9a-f]{64}$/i.test(value)) { + throw new Error(`${name} must contain exactly 64 hexadecimal characters`); + } + return Buffer.from(value, "hex"); + } + static getInstance(): SystemCrypto { if (!this.instance) { this.instance = new SystemCrypto(); @@ -21,8 +50,8 @@ class SystemCrypto { async initializeJWTSecret(): Promise { try { - const envSecret = process.env.JWT_SECRET; - if (envSecret && envSecret.length >= 64) { + const envSecret = await this.readExternalSecret("JWT_SECRET", 64); + if (envSecret) { this.jwtSecret = envSecret; return; } @@ -39,7 +68,6 @@ class SystemCrypto { databaseLogger.success("JWT secret loaded from .env file", { operation: "jwt_init_from_file_success", secretLength: jwtMatch[1].length, - secretPrefix: jwtMatch[1].substring(0, 8) + "...", }); return; } else { @@ -56,6 +84,9 @@ class SystemCrypto { // expected - env file may not exist } + if (process.env.TERMIX_REQUIRE_EXTERNAL_SECRETS === "true") { + this.requireExternalSecret("JWT_SECRET"); + } await this.generateAndGuideUser(); } catch (error) { databaseLogger.error("Failed to initialize JWT secret", error, { @@ -77,9 +108,9 @@ class SystemCrypto { const dataDir = process.env.DATA_DIR || "./db/data"; const envPath = path.join(dataDir, ".env"); - const envKey = process.env.DATABASE_KEY; - if (envKey && envKey.length >= 64) { - this.databaseKey = Buffer.from(envKey, "hex"); + const envKey = await this.readExternalSecret("DATABASE_KEY", 64); + if (envKey) { + this.databaseKey = this.parseExternalHexKey("DATABASE_KEY", envKey); return; } @@ -97,6 +128,9 @@ class SystemCrypto { // expected - env file may not exist } + if (process.env.TERMIX_REQUIRE_EXTERNAL_SECRETS === "true") { + this.requireExternalSecret("DATABASE_KEY"); + } await this.generateAndGuideDatabaseKey(); } catch (error) { databaseLogger.error("Failed to initialize database key", error, { @@ -119,9 +153,9 @@ class SystemCrypto { const dataDir = process.env.DATA_DIR || "./db/data"; const envPath = path.join(dataDir, ".env"); - const envKey = process.env.ENCRYPTION_KEY; - if (envKey && envKey.length >= 64) { - this.encryptionKey = Buffer.from(envKey, "hex"); + const envKey = await this.readExternalSecret("ENCRYPTION_KEY", 64); + if (envKey) { + this.encryptionKey = this.parseExternalHexKey("ENCRYPTION_KEY", envKey); return; } @@ -137,6 +171,9 @@ class SystemCrypto { // expected - env file may not exist } + if (process.env.TERMIX_REQUIRE_EXTERNAL_SECRETS === "true") { + this.requireExternalSecret("ENCRYPTION_KEY"); + } await this.generateAndGuideEncryptionKey(); } catch (error) { databaseLogger.error("Failed to initialize encryption key", error, { @@ -156,8 +193,8 @@ class SystemCrypto { async initializeInternalAuthToken(): Promise { try { - const envToken = process.env.INTERNAL_AUTH_TOKEN; - if (envToken && envToken.length >= 32) { + const envToken = await this.readExternalSecret("INTERNAL_AUTH_TOKEN", 32); + if (envToken) { this.internalAuthToken = envToken; return; } @@ -177,6 +214,9 @@ class SystemCrypto { // expected - env file may not exist } + if (process.env.TERMIX_REQUIRE_EXTERNAL_SECRETS === "true") { + this.requireExternalSecret("INTERNAL_AUTH_TOKEN"); + } await this.generateAndGuideInternalAuthToken(); } catch (error) { databaseLogger.error("Failed to initialize internal auth token", error, { diff --git a/src/backend/utils/ws-auth.ts b/src/backend/utils/ws-auth.ts new file mode 100644 index 00000000..ae2314c7 --- /dev/null +++ b/src/backend/utils/ws-auth.ts @@ -0,0 +1,26 @@ +import type { IncomingMessage } from "http"; + +const JWT_PROTOCOL_PREFIX = "termix.jwt."; + +export function extractWebSocketToken( + req: IncomingMessage, +): string | undefined { + const cookieHeader = req.headers.cookie; + if (cookieHeader) { + const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/); + if (match) return decodeURIComponent(match[1]); + } + + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + return authHeader.slice("Bearer ".length); + } + + const protocols = String(req.headers["sec-websocket-protocol"] || "") + .split(",") + .map((protocol) => protocol.trim()); + const jwtProtocol = protocols.find((protocol) => + protocol.startsWith(JWT_PROTOCOL_PREFIX), + ); + return jwtProtocol?.slice(JWT_PROTOCOL_PREFIX.length); +} diff --git a/src/ui/features/docker/components/ConsoleTerminal.tsx b/src/ui/features/docker/components/ConsoleTerminal.tsx index 0e17022b..194c196d 100644 --- a/src/ui/features/docker/components/ConsoleTerminal.tsx +++ b/src/ui/features/docker/components/ConsoleTerminal.tsx @@ -313,6 +313,7 @@ function ConsoleTerminalInner({ window.location.port === ""); let baseWsUrl: string; + let wsProtocols: string[] = []; if (isDev) { baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`; } else if (isElectronApp) { @@ -331,12 +332,13 @@ function ConsoleTerminalInner({ toast.error(t("errors.remoteServerRequired")); return; } - baseWsUrl = resolvedUrl; + baseWsUrl = resolvedUrl.url; + wsProtocols = resolvedUrl.protocols; } else { baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`; } - const ws = new WebSocket(baseWsUrl); + const ws = new WebSocket(baseWsUrl, wsProtocols); ws.onopen = () => { const cols = terminal.cols || 80; diff --git a/src/ui/features/guacamole/GuacamoleDisplay.tsx b/src/ui/features/guacamole/GuacamoleDisplay.tsx index 2227eb56..7fee2389 100644 --- a/src/ui/features/guacamole/GuacamoleDisplay.tsx +++ b/src/ui/features/guacamole/GuacamoleDisplay.tsx @@ -250,17 +250,18 @@ export const GuacamoleDisplay = forwardRef< const origin = await resolveConnectionOrigin({ connectionType: connectionProtocol, }); - wsBase = await buildOriginWsUrl({ + const target = await buildOriginWsUrl({ origin, localPort: 30008, localPath: "/guacamole/websocket/", remotePath: "/guacamole/websocket/", includeJwt: false, }); - if (!wsBase) { + if (!target) { onError?.(t("errors.remoteServerRequired")); return null; } + wsBase = target.url; } else { wsBase = buildGuacamoleWebSocketBaseUrl({ isDev, diff --git a/src/ui/features/serial/Serial.tsx b/src/ui/features/serial/Serial.tsx index 851abb15..d052b2e2 100644 --- a/src/ui/features/serial/Serial.tsx +++ b/src/ui/features/serial/Serial.tsx @@ -10,6 +10,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { useTranslation } from "react-i18next"; import { TriangleAlert } from "lucide-react"; import { isElectron } from "@/lib/electron"; +import { websocketAuthProtocols } from "@/lib/ws-auth"; import { useTheme } from "@/components/theme-provider"; import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme"; import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes"; @@ -102,9 +103,7 @@ export const Serial = forwardRef(function Serial( const buildWsUrl = useCallback(() => { // Serial is always local -- the device is physically attached to this // desktop machine, so it never routes through a remote server. - const token = localStorage.getItem("jwt"); - const base = "ws://127.0.0.1:30011"; - return token ? `${base}?token=${encodeURIComponent(token)}` : base; + return "ws://127.0.0.1:30011"; }, []); const disconnectWs = useCallback(() => { @@ -124,7 +123,10 @@ export const Serial = forwardRef(function Serial( return; } - const ws = new WebSocket(url); + const ws = new WebSocket( + url, + websocketAuthProtocols(localStorage.getItem("jwt")), + ); wsRef.current = ws; ws.onopen = () => { diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index c23aa59b..2c8d7b00 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -1201,6 +1201,7 @@ const TerminalInner = forwardRef( window.location.port === ""); let baseWsUrl: string; + let wsProtocols: string[] = []; if (isDev) { baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`; @@ -1223,7 +1224,8 @@ const TerminalInner = forwardRef( isConnectingRef.current = false; return; } - baseWsUrl = resolvedUrl; + baseWsUrl = resolvedUrl.url; + wsProtocols = resolvedUrl.protocols; } else { baseWsUrl = `${getBasePath()}/ssh/websocket/`; } @@ -1246,7 +1248,7 @@ const TerminalInner = forwardRef( connectionTimeoutRef.current = null; } - const ws = new WebSocket(baseWsUrl); + const ws = new WebSocket(baseWsUrl, wsProtocols); webSocketRef.current = ws; wasDisconnectedBySSH.current = false; updateConnectionError(null); diff --git a/src/ui/lib/connection-origin.ts b/src/ui/lib/connection-origin.ts index fb268d39..dd2ba28e 100644 --- a/src/ui/lib/connection-origin.ts +++ b/src/ui/lib/connection-origin.ts @@ -1,4 +1,5 @@ import { isElectron } from "@/lib/electron"; +import { websocketAuthProtocols } from "@/lib/ws-auth"; export type ConnectionOrigin = "local" | "remote"; @@ -75,6 +76,11 @@ async function getRemoteConnectionTarget(): Promise { +}): Promise { if (origin === "local") { - let url = `ws://127.0.0.1:${localPort}${localPath}`; - if (includeJwt) { - const token = localStorage.getItem("jwt"); - if (token) url += `?token=${encodeURIComponent(token)}`; - } - return url; + const token = includeJwt ? localStorage.getItem("jwt") : null; + return { + url: `ws://127.0.0.1:${localPort}${localPath}`, + protocols: websocketAuthProtocols(token), + }; } const remote = await getRemoteConnectionTarget(); @@ -106,9 +111,8 @@ export async function buildOriginWsUrl({ const wsHost = remote.serverUrl .replace(/^https?:\/\//, "") .replace(/\/$/, ""); - let url = `${wsProtocol}${wsHost}${remotePath}`; - if (includeJwt && remote.jwt) { - url += `?token=${encodeURIComponent(remote.jwt)}`; - } - return url; + return { + url: `${wsProtocol}${wsHost}${remotePath}`, + protocols: websocketAuthProtocols(includeJwt ? remote.jwt : null), + }; } diff --git a/src/ui/lib/ws-auth.ts b/src/ui/lib/ws-auth.ts new file mode 100644 index 00000000..611e3fb8 --- /dev/null +++ b/src/ui/lib/ws-auth.ts @@ -0,0 +1,5 @@ +const JWT_PROTOCOL_PREFIX = "termix.jwt."; + +export function websocketAuthProtocols(token: string | null): string[] { + return token ? [`${JWT_PROTOCOL_PREFIX}${token}`] : []; +} diff --git a/src/ui/sidebar/SerialPanel.tsx b/src/ui/sidebar/SerialPanel.tsx index 8959edf3..d610d00b 100644 --- a/src/ui/sidebar/SerialPanel.tsx +++ b/src/ui/sidebar/SerialPanel.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { RefreshCw, Usb, TriangleAlert } from "lucide-react"; import { Input } from "@/components/input"; import { isElectron } from "@/lib/electron"; +import { websocketAuthProtocols } from "@/lib/ws-auth"; import type { SerialConfig } from "@/types/ui-types"; const BAUD_RATES = [ @@ -34,9 +35,7 @@ export function SerialPanel({ onConnect }: SerialPanelProps) { const buildWsUrl = () => { // Serial is always local -- the device is physically attached to this // desktop machine, so it never routes through a remote server. - const token = localStorage.getItem("jwt"); - const base = "ws://127.0.0.1:30011"; - return token ? `${base}?token=${encodeURIComponent(token)}` : base; + return "ws://127.0.0.1:30011"; }; const refreshPorts = useCallback(() => { @@ -48,7 +47,10 @@ export function SerialPanel({ onConnect }: SerialPanelProps) { return; } - const ws = new WebSocket(url); + const ws = new WebSocket( + url, + websocketAuthProtocols(localStorage.getItem("jwt")), + ); ws.onopen = () => ws.send(JSON.stringify({ type: "list_ports" })); ws.onmessage = (ev) => { try { diff --git a/src/ui/tests/lib/connection-origin.test.ts b/src/ui/tests/lib/connection-origin.test.ts index 41bb90f2..40a8ae0a 100644 --- a/src/ui/tests/lib/connection-origin.test.ts +++ b/src/ui/tests/lib/connection-origin.test.ts @@ -116,18 +116,21 @@ describe("buildOriginWsUrl", () => { it("carries the local JWT by default", async () => { // Every interactive channel on the embedded backend relies on this. - const url = await buildOriginWsUrl({ + const target = await buildOriginWsUrl({ origin: "local", localPort: 30009, localPath: "/docker/console/", remotePath: "/docker/console/", }); - expect(url).toBe("ws://127.0.0.1:30009/docker/console/?token=local-jwt"); + expect(target).toEqual({ + url: "ws://127.0.0.1:30009/docker/console/", + protocols: ["termix.jwt.local-jwt"], + }); }); it("omits it only when a caller asks", async () => { - const url = await buildOriginWsUrl({ + const target = await buildOriginWsUrl({ origin: "local", localPort: 30009, localPath: "/docker/console/", @@ -135,7 +138,10 @@ describe("buildOriginWsUrl", () => { includeJwt: false, }); - expect(url).toBe("ws://127.0.0.1:30009/docker/console/"); + expect(target).toEqual({ + url: "ws://127.0.0.1:30009/docker/console/", + protocols: [], + }); }); it("does not duplicate the Guacamole token on remote connections", async () => { @@ -149,7 +155,7 @@ describe("buildOriginWsUrl", () => { }, }; - const url = await buildOriginWsUrl({ + const target = await buildOriginWsUrl({ origin: "remote", localPort: 30008, localPath: "/guacamole/websocket/", @@ -157,19 +163,25 @@ describe("buildOriginWsUrl", () => { includeJwt: false, }); - expect(url).toBe("wss://termix.example/guacamole/websocket/"); + expect(target).toEqual({ + url: "wss://termix.example/guacamole/websocket/", + protocols: [], + }); }); it("leaves the URL alone when there is no token stored", async () => { delete store.jwt; - const url = await buildOriginWsUrl({ + const target = await buildOriginWsUrl({ origin: "local", localPort: 30002, localPath: "", remotePath: "/ssh/websocket/", }); - expect(url).toBe("ws://127.0.0.1:30002"); + expect(target).toEqual({ + url: "ws://127.0.0.1:30002", + protocols: [], + }); }); }); diff --git a/src/ui/tests/lib/ws-auth.test.ts b/src/ui/tests/lib/ws-auth.test.ts new file mode 100644 index 00000000..9360a6ce --- /dev/null +++ b/src/ui/tests/lib/ws-auth.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { websocketAuthProtocols } from "@/lib/ws-auth"; + +describe("websocketAuthProtocols", () => { + it("moves a JWT into the WebSocket protocol header", () => { + expect(websocketAuthProtocols("header.payload.sig")).toEqual([ + "termix.jwt.header.payload.sig", + ]); + }); + + it("does not advertise an authentication protocol without a token", () => { + expect(websocketAuthProtocols(null)).toEqual([]); + }); +});