diff --git a/apps/frontend/electron.main.js b/apps/frontend/electron.main.js index 544a3151..cb84b328 100644 --- a/apps/frontend/electron.main.js +++ b/apps/frontend/electron.main.js @@ -2,10 +2,14 @@ const { app, BrowserWindow, ipcMain, safeStorage, shell, powerMonitor, session } = require("electron") const path = require("path") const { fork } = require("child_process") +const crypto = require("crypto") const { createApplicationMenu } = require("./menu") let serverProcess const ELECTRON = "Electron" +// Per-launch secret shared with the backend so only this app's renderer can open +// the WebSocket. Regenerated every launch; never persisted. +const wsToken = crypto.randomBytes(32).toString("hex") function startServer() { if (app.isPackaged) { const serverPath = path.join(process.resourcesPath, "server-backend.cjs") @@ -14,6 +18,7 @@ function startServer() { env: { ...process.env, DEPLOYMENT_MODE: ELECTRON, + ELECTRON_WS_TOKEN: wsToken, PROCESS_RESOURCES_PATH: process.resourcesPath, DATA_DIR: path.join(app.getPath("userData"), "metrics-data"), }, @@ -39,6 +44,8 @@ function createWindow() { nodeIntegration: false, contextIsolation: true, preload: path.join(__dirname, "preload.js"), + // Hand the per-launch token to the preload (readable via process.argv). + additionalArguments: [`--valkey-admin-ws-token=${wsToken}`], }, }) diff --git a/apps/frontend/preload.js b/apps/frontend/preload.js index c081778d..c640c79c 100644 --- a/apps/frontend/preload.js +++ b/apps/frontend/preload.js @@ -1,6 +1,14 @@ // eslint-disable-next-line @typescript-eslint/no-require-imports const { contextBridge, ipcRenderer } = require("electron") +// The main process passes the per-launch WebSocket token via additionalArguments. +const wsTokenArg = process.argv.find((arg) => arg.startsWith("--valkey-admin-ws-token=")) +const wsToken = wsTokenArg ? wsTokenArg.slice("--valkey-admin-ws-token=".length) : "" + +contextBridge.exposeInMainWorld("valkeyAdminRuntime", { + wsToken, +}) + contextBridge.exposeInMainWorld("secureStorage", { encrypt: (password) => ipcRenderer.invoke("secure-storage:encrypt", password), decrypt: (encrypted) => ipcRenderer.invoke("secure-storage:decrypt", encrypted), diff --git a/apps/frontend/src/state/epics/wsEpics.ts b/apps/frontend/src/state/epics/wsEpics.ts index faef520b..937d4b4b 100644 --- a/apps/frontend/src/state/epics/wsEpics.ts +++ b/apps/frontend/src/state/epics/wsEpics.ts @@ -29,7 +29,8 @@ let socket$: WebSocketSubject | null = null const getWebsocketURL = () => { // If it's an Electron deployment if (window.location.protocol === "file:") { - return "ws://localhost:8080" + const token = window.valkeyAdminRuntime?.wsToken + return token ? `ws://localhost:8080?token=${encodeURIComponent(token)}` : "ws://localhost:8080" } const protocol = window.location.protocol === "https:" ? "wss" : "ws" diff --git a/apps/frontend/src/types/electron.d.ts b/apps/frontend/src/types/electron.d.ts index dbbac7a1..401edc8e 100644 --- a/apps/frontend/src/types/electron.d.ts +++ b/apps/frontend/src/types/electron.d.ts @@ -2,8 +2,13 @@ export interface ElectronNavigation { onNavigate: (callback: (route: string) => void) => void } +export interface ValkeyAdminRuntime { + wsToken: string +} + declare global { interface Window { electronNavigation: ElectronNavigation + valkeyAdminRuntime?: ValkeyAdminRuntime } } diff --git a/apps/server/src/__tests__/websocket-origin.test.ts b/apps/server/src/__tests__/websocket-origin.test.ts index 5f7d1b9f..be945ec1 100644 --- a/apps/server/src/__tests__/websocket-origin.test.ts +++ b/apps/server/src/__tests__/websocket-origin.test.ts @@ -4,16 +4,18 @@ import { DEPLOYMENT_TYPE } from "valkey-common" import { isAllowedWebSocketOrigin } from "../websocket-origin" import type { IncomingMessage } from "http" -const makeRequest = (headers: Record) => - ({ headers }) as IncomingMessage +const makeRequest = ({ url, ...headers }: Record) => + ({ headers, url }) as IncomingMessage describe("isAllowedWebSocketOrigin", () => { const originalDeploymentMode = process.env.DEPLOYMENT_MODE const originalAllowedOrigins = process.env.VALKEY_ADMIN_ALLOWED_WS_ORIGINS + const originalWsToken = process.env.ELECTRON_WS_TOKEN afterEach(() => { process.env.DEPLOYMENT_MODE = originalDeploymentMode process.env.VALKEY_ADMIN_ALLOWED_WS_ORIGINS = originalAllowedOrigins + process.env.ELECTRON_WS_TOKEN = originalWsToken }) it("rejects requests without an origin header", () => { @@ -22,28 +24,69 @@ describe("isAllowedWebSocketOrigin", () => { assert.strictEqual(isAllowedWebSocketOrigin(makeRequest({ host: "localhost:8080" })), false) }) - it("allows packaged Electron origins", () => { + it("allows packaged Electron origins only with a valid per-launch token", () => { process.env.DEPLOYMENT_MODE = DEPLOYMENT_TYPE.ELECTRON + process.env.ELECTRON_WS_TOKEN = "secret-token" + + for (const origin of ["file://", "null"]) { + assert.strictEqual( + isAllowedWebSocketOrigin(makeRequest({ origin, host: "localhost:8080", url: "/?token=secret-token" })), + true, + `${origin} with valid token should be allowed`, + ) + } + }) + + it("rejects Electron non-web origins when the token is missing or wrong", () => { + process.env.DEPLOYMENT_MODE = DEPLOYMENT_TYPE.ELECTRON + process.env.ELECTRON_WS_TOKEN = "secret-token" + + for (const origin of ["file://", "null"]) { + // No token + assert.strictEqual( + isAllowedWebSocketOrigin(makeRequest({ origin, host: "localhost:8080", url: "/" })), + false, + `${origin} without a token must be rejected`, + ) + // Wrong token + assert.strictEqual( + isAllowedWebSocketOrigin(makeRequest({ origin, host: "localhost:8080", url: "/?token=nope" })), + false, + `${origin} with a wrong token must be rejected`, + ) + } + }) + + it("fails closed when no server-side token is provisioned", () => { + process.env.DEPLOYMENT_MODE = DEPLOYMENT_TYPE.ELECTRON + delete process.env.ELECTRON_WS_TOKEN assert.strictEqual( - isAllowedWebSocketOrigin(makeRequest({ origin: "file://", host: "localhost:8080" })), - true, - ) - assert.strictEqual( - isAllowedWebSocketOrigin(makeRequest({ origin: "null", host: "localhost:8080" })), - true, + isAllowedWebSocketOrigin(makeRequest({ origin: "null", host: "localhost:8080", url: "/?token=anything" })), + false, ) }) - it("allows loopback origins in Electron mode and blocks remote origins", () => { + it("allows loopback origins in Electron mode only with a token and blocks remote origins", () => { process.env.DEPLOYMENT_MODE = DEPLOYMENT_TYPE.ELECTRON + process.env.ELECTRON_WS_TOKEN = "secret-token" assert.strictEqual( - isAllowedWebSocketOrigin(makeRequest({ origin: "http://localhost:5173", host: "localhost:8080" })), + isAllowedWebSocketOrigin( + makeRequest({ origin: "http://localhost:5173", host: "localhost:8080", url: "/?token=secret-token" }), + ), true, ) + // Loopback origin but no token + assert.strictEqual( + isAllowedWebSocketOrigin(makeRequest({ origin: "http://localhost:5173", host: "localhost:8080", url: "/" })), + false, + ) + // Remote origin is rejected regardless of token assert.strictEqual( - isAllowedWebSocketOrigin(makeRequest({ origin: "https://evil.example", host: "localhost:8080" })), + isAllowedWebSocketOrigin( + makeRequest({ origin: "https://evil.example", host: "localhost:8080", url: "/?token=secret-token" }), + ), false, ) }) diff --git a/apps/server/src/websocket-origin.ts b/apps/server/src/websocket-origin.ts index 8a8172e5..d93ededa 100644 --- a/apps/server/src/websocket-origin.ts +++ b/apps/server/src/websocket-origin.ts @@ -1,10 +1,12 @@ import { DEPLOYMENT_TYPE } from "valkey-common" +import { timingSafeEqual } from "crypto" import type { IncomingMessage } from "http" const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]) const LOCAL_PROTOCOLS = new Set(["http:", "https:"]) -// Electron file:// renderers may send Origin as "file://" or "null" on the WebSocket handshake. -const ELECTRON_ORIGINS = new Set(["null", "file://"]) +// Non-web origins a file:// renderer may present; accepted only with a valid token. +const ELECTRON_NONWEB_ORIGINS = new Set(["null", "file://"]) +const ELECTRON_WS_TOKEN_ENV = "ELECTRON_WS_TOKEN" const normalizeHost = (hostname: string) => hostname.replace(/^\[|]$/g, "").toLowerCase() @@ -34,6 +36,29 @@ const isSameOrigin = (origin: URL, req: IncomingMessage) => { return normalizeOrigin(origin.origin) === `${origin.protocol}//${hostHeader.toLowerCase()}` } +// Length-independent comparison so the token can't be recovered by timing. +const tokensMatch = (a: string, b: string) => { + const ab = Buffer.from(a) + const bb = Buffer.from(b) + // timingSafeEqual requires equal lengths; the token is fixed-length so this + // guard leaks nothing useful. + return ab.length === bb.length && timingSafeEqual(ab, bb) +} + +// The renderer appends the per-launch token as `?token=...` on the WS URL. +const hasValidElectronToken = (req: IncomingMessage) => { + const expected = process.env[ELECTRON_WS_TOKEN_ENV] + // Fail closed: if no token was provisioned, the token gate cannot be satisfied. + if (!expected) return false + try { + const url = new URL(req.url ?? "", "http://localhost") + const provided = url.searchParams.get("token") + return provided != null && tokensMatch(provided, expected) + } catch { + return false + } +} + export const isAllowedWebSocketOrigin = (req: IncomingMessage) => { // Browsers send Origin on WebSocket handshakes, so we can reject cross-site pages before accepting the upgrade. const originHeader = req.headers.origin @@ -51,11 +76,16 @@ export const isAllowedWebSocketOrigin = (req: IncomingMessage) => { } if (deploymentMode === DEPLOYMENT_TYPE.ELECTRON) { - try { - return ELECTRON_ORIGINS.has(normalizedOrigin) || isLoopbackOrigin(new URL(normalizedOrigin)) - } catch { // new URL can technically throw - return false + // Require a valid per-launch token alongside the local renderer origin. + let originLooksLocal = ELECTRON_NONWEB_ORIGINS.has(normalizedOrigin) + if (!originLooksLocal) { + try { + originLooksLocal = isLoopbackOrigin(new URL(normalizedOrigin)) + } catch { // new URL can technically throw + originLooksLocal = false + } } + return originLooksLocal && hasValidElectronToken(req) } try { // for Web deployment — only same origin is allowed