From e527e2ab77ea73905ba7aaa6e8dd0eb511c8d9e4 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Wed, 21 Aug 2024 15:04:39 -0700 Subject: [PATCH] Add authkey header for requests to the backend (#256) With this PR, Electron will generate a new authorization key that the Go backend will look for in any incoming requests. The Electron backend will inject this header with all requests to the backend to ensure no additional work is required on the frontend. This also adds a `fetchutil` abstraction that will use the Electron `net` module when calls are made from the Electron backend to the Go backend. When using the `node:fetch` module, Electron can't inject headers to requests. The Electron `net` module is also faster than the Node module. This also breaks out platform functions in emain into their own file so other emain modules can import them. --- cmd/server/main-server.go | 9 ++++- emain/authkey.ts | 25 ++++++++++++ emain/emain.ts | 78 +++++++++--------------------------- emain/platform.ts | 69 +++++++++++++++++++++++++++++++ emain/preload.ts | 11 ++--- frontend/app/store/global.ts | 1 + frontend/app/store/wos.ts | 1 + frontend/types/custom.d.ts | 1 + frontend/util/fetchutil.ts | 20 +++++++++ pkg/authkey/authkey.go | 38 ++++++++++++++++++ pkg/web/web.go | 19 ++++----- pkg/web/ws.go | 9 +++++ 12 files changed, 206 insertions(+), 75 deletions(-) create mode 100644 emain/authkey.ts create mode 100644 emain/platform.ts create mode 100644 frontend/util/fetchutil.ts create mode 100644 pkg/authkey/authkey.go diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 4dae197d7..94c6a1f44 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -17,6 +17,7 @@ import ( "syscall" "time" + "github.com/wavetermdev/thenextwave/pkg/authkey" "github.com/wavetermdev/thenextwave/pkg/filestore" "github.com/wavetermdev/thenextwave/pkg/service" "github.com/wavetermdev/thenextwave/pkg/telemetry" @@ -165,7 +166,13 @@ func main() { wavebase.WaveVersion = WaveVersion wavebase.BuildTime = BuildTime - err := service.ValidateServiceMap() + err := authkey.SetAuthKeyFromEnv() + if err != nil { + log.Printf("error setting auth key: %v\n", err) + return + } + + err = service.ValidateServiceMap() if err != nil { log.Printf("error validating service map: %v\n", err) return diff --git a/emain/authkey.ts b/emain/authkey.ts new file mode 100644 index 000000000..128730945 --- /dev/null +++ b/emain/authkey.ts @@ -0,0 +1,25 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { getWebServerEndpoint, getWSServerEndpoint } from "@/util/endpoints"; +import { ipcMain } from "electron"; + +const AuthKeyHeader = "X-AuthKey"; +export const AuthKeyEnv = "AUTH_KEY"; +export const AuthKey = crypto.randomUUID(); + +console.log("authKey", AuthKey); + +ipcMain.on("get-auth-key", (event) => { + event.returnValue = AuthKey; +}); + +export function configureAuthKeyRequestInjection(session: Electron.Session) { + const filter: Electron.WebRequestFilter = { + urls: [`${getWebServerEndpoint()}/*`, `${getWSServerEndpoint()}/*`], + }; + session.webRequest.onBeforeSendHeaders(filter, (details, callback) => { + details.requestHeaders[AuthKeyHeader] = AuthKey; + callback({ requestHeaders: details.requestHeaders }); + }); +} diff --git a/emain/emain.ts b/emain/emain.ts index 2383d7fb0..020d22f35 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -5,7 +5,6 @@ import * as electron from "electron"; import { FastAverageColor } from "fast-average-color"; import fs from "fs"; import * as child_process from "node:child_process"; -import os from "os"; import * as path from "path"; import { PNG } from "pngjs"; import * as readline from "readline"; @@ -16,9 +15,20 @@ import winston from "winston"; import { initGlobal } from "../frontend/app/store/global"; import * as services from "../frontend/app/store/services"; import { WSServerEndpointVarName, WebServerEndpointVarName, getWebServerEndpoint } from "../frontend/util/endpoints"; -import { WaveDevVarName, WaveDevViteVarName } from "../frontend/util/isdev"; -import * as keyutil from "../frontend/util/keyutil"; +import { fetch } from "../frontend/util/fetchutil"; import { fireAndForget } from "../frontend/util/util"; +import { AuthKey, AuthKeyEnv, configureAuthKeyRequestInjection } from "./authkey"; +import { + getElectronAppBasePath, + getGoAppBasePath, + getWaveHomeDir, + getWaveSrvCwd, + getWaveSrvPath, + isDev, + isDevVite, + unameArch, + unamePlatform, +} from "./platform"; import { configureAutoUpdater, updater } from "./updater"; const electronApp = electron.app; @@ -27,7 +37,6 @@ let WaveBuildTime = 0; // set by WAVESRV-ESTART const WaveAppPathVarName = "WAVETERM_APP_PATH"; const WaveSrvReadySignalPidVarName = "WAVETERM_READY_SIGNAL_PID"; -const AuthKeyFile = "waveterm.authkey"; electron.nativeTheme.themeSource = "dark"; type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string; readyPromise: Promise }; @@ -44,35 +53,14 @@ let globalIsRelaunching = false; let wasActive = true; let wasInFg = true; -const isDev = !electronApp.isPackaged; -const isDevVite = isDev && process.env.ELECTRON_RENDERER_URL; -if (isDev) { - process.env[WaveDevVarName] = "1"; -} -if (isDevVite) { - process.env[WaveDevViteVarName] = "1"; -} - let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null; -electronApp.setName(isDev ? "TheNextWave (Dev)" : "TheNextWave"); -const unamePlatform = process.platform; -let unameArch: string = process.arch; -if (unameArch == "x64") { - unameArch = "amd64"; -} -keyutil.setKeyUtilPlatform(unamePlatform); - -// must match golang -function getWaveHomeDir() { - return path.join(os.homedir(), isDev ? ".w2-dev" : ".w2"); -} const waveHome = getWaveHomeDir(); const oldConsoleLog = console.log; const loggerTransports: winston.transport[] = [ - new winston.transports.File({ filename: path.join(waveHome, "waveterm-app.log"), level: "info" }), + new winston.transports.File({ filename: path.join(getWaveHomeDir(), "waveterm-app.log"), level: "info" }), ]; if (isDev) { loggerTransports.push(new winston.transports.Console()); @@ -110,29 +98,6 @@ if (isDev) { initGlobal({ windowId: null, clientId: null, platform: unamePlatform, environment: "electron" }); -function getElectronAppBasePath(): string { - return path.dirname(__dirname); -} - -function getGoAppBasePath(): string { - return getElectronAppBasePath().replace("app.asar", "app.asar.unpacked"); -} - -const wavesrvBinName = `wavesrv.${unameArch}`; - -function getWaveSrvPath(): string { - if (process.platform === "win32") { - const winBinName = `${wavesrvBinName}.exe`; - const appPath = path.join(getGoAppBasePath(), "bin", winBinName); - return `${appPath}`; - } - return path.join(getGoAppBasePath(), "bin", wavesrvBinName); -} - -function getWaveSrvCwd(): string { - return getWaveHomeDir(); -} - function getWindowForEvent(event: Electron.IpcMainEvent): Electron.BrowserWindow { const windowId = event.sender.id; return electron.BrowserWindow.fromId(windowId); @@ -148,6 +113,7 @@ function runWaveSrv(): Promise { const envCopy = { ...process.env }; envCopy[WaveAppPathVarName] = getGoAppBasePath(); envCopy[WaveSrvReadySignalPidVarName] = process.pid.toString(); + envCopy[AuthKeyEnv] = AuthKey; const waveSrvCmd = getWaveSrvPath(); console.log("trying to run local server", waveSrvCmd); const proc = child_process.spawn(getWaveSrvPath(), { @@ -197,6 +163,7 @@ function runWaveSrv(): Promise { applicationVersion: "v" + WaveVersion, version: (isDev ? "dev-" : "") + String(WaveBuildTime), }); + configureAuthKeyRequestInjection(electron.session.defaultSession); waveSrvReadyResolve(true); return; } @@ -465,6 +432,7 @@ function createBrowserWindow( console.log("window-open denied", url); return { action: "deny" }; }); + configureAuthKeyRequestInjection(win.webContents.session); return win; } @@ -548,12 +516,6 @@ function ensureBoundsAreVisible(bounds: electron.Rectangle): electron.Rectangle return bounds; } -electron.ipcMain.on("getIsDev", (event) => { - event.returnValue = isDev; -}); -electron.ipcMain.on("getPlatform", (event, url) => { - event.returnValue = unamePlatform; -}); // Listen for the open-external event from the renderer process electron.ipcMain.on("open-external", (event, url) => { if (url && typeof url === "string") { @@ -571,7 +533,7 @@ electron.ipcMain.on("download", (event, payload) => { window.webContents.downloadURL(streamingUrl); }); -electron.ipcMain.on("getCursorPoint", (event) => { +electron.ipcMain.on("get-cursor-point", (event) => { const window = electron.BrowserWindow.fromWebContents(event.sender); const screenPoint = electron.screen.getCursorScreenPoint(); const windowRect = window.getContentBounds(); @@ -582,7 +544,7 @@ electron.ipcMain.on("getCursorPoint", (event) => { event.returnValue = retVal; }); -electron.ipcMain.on("getEnv", (event, varName) => { +electron.ipcMain.on("get-env", (event, varName) => { event.returnValue = process.env[varName] ?? null; }); @@ -617,7 +579,7 @@ async function createNewWaveWindow() { newBrowserWindow.show(); } -electron.ipcMain.on("openNewWindow", () => fireAndForget(createNewWaveWindow)); +electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenuItem[]) => { const window = electron.BrowserWindow.fromWebContents(event.sender); diff --git a/emain/platform.ts b/emain/platform.ts new file mode 100644 index 000000000..d9a2e8893 --- /dev/null +++ b/emain/platform.ts @@ -0,0 +1,69 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { WaveDevVarName, WaveDevViteVarName } from "@/util/isdev"; +import { app, ipcMain } from "electron"; +import os from "os"; +import path from "path"; +import * as keyutil from "../frontend/util/keyutil"; + +const isDev = !app.isPackaged; +const isDevVite = isDev && process.env.ELECTRON_RENDERER_URL; +if (isDev) { + process.env[WaveDevVarName] = "1"; +} +if (isDevVite) { + process.env[WaveDevViteVarName] = "1"; +} + +app.setName(isDev ? "TheNextWave (Dev)" : "TheNextWave"); +const unamePlatform = process.platform; +const unameArch: string = process.arch === "x64" ? "amd64" : process.arch; +keyutil.setKeyUtilPlatform(unamePlatform); + +ipcMain.on("get-is-dev", (event) => { + event.returnValue = isDev; +}); +ipcMain.on("get-platform", (event, url) => { + event.returnValue = unamePlatform; +}); + +// must match golang +function getWaveHomeDir() { + return path.join(os.homedir(), isDev ? ".w2-dev" : ".w2"); +} + +function getElectronAppBasePath(): string { + return path.dirname(__dirname); +} + +function getGoAppBasePath(): string { + return getElectronAppBasePath().replace("app.asar", "app.asar.unpacked"); +} + +const wavesrvBinName = `wavesrv.${unameArch}`; + +function getWaveSrvPath(): string { + if (process.platform === "win32") { + const winBinName = `${wavesrvBinName}.exe`; + const appPath = path.join(getGoAppBasePath(), "bin", winBinName); + return `${appPath}`; + } + return path.join(getGoAppBasePath(), "bin", wavesrvBinName); +} + +function getWaveSrvCwd(): string { + return getWaveHomeDir(); +} + +export { + getElectronAppBasePath, + getGoAppBasePath, + getWaveHomeDir, + getWaveSrvCwd, + getWaveSrvPath, + isDev, + isDevVite, + unameArch, + unamePlatform, +}; diff --git a/emain/preload.ts b/emain/preload.ts index 77d44dc6a..19d33848a 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -4,10 +4,11 @@ const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("api", { - getIsDev: () => ipcRenderer.sendSync("getIsDev"), - getPlatform: () => ipcRenderer.sendSync("getPlatform"), - getCursorPoint: () => ipcRenderer.sendSync("getCursorPoint"), - openNewWindow: () => ipcRenderer.send("openNewWindow"), + getAuthKey: () => ipcRenderer.sendSync("get-auth-key"), + getIsDev: () => ipcRenderer.sendSync("get-is-dev"), + getPlatform: () => ipcRenderer.sendSync("get-platform"), + getCursorPoint: () => ipcRenderer.sendSync("get-cursor-point"), + openNewWindow: () => ipcRenderer.send("open-new-window"), showContextMenu: (menu, position) => ipcRenderer.send("contextmenu-show", menu, position), onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", (_event, id) => callback(id)), downloadFile: (filePath) => ipcRenderer.send("download", { filePath }), @@ -18,7 +19,7 @@ contextBridge.exposeInMainWorld("api", { console.error("Invalid URL passed to openExternal:", url); } }, - getEnv: (varName) => ipcRenderer.sendSync("getEnv", varName), + getEnv: (varName) => ipcRenderer.sendSync("get-env", varName), onFullScreenChange: (callback) => ipcRenderer.on("fullscreen-change", (_event, isFullScreen) => callback(isFullScreen)), onUpdaterStatusChange: (callback) => ipcRenderer.on("app-update-status", (_event, status) => callback(status)), diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 697ac94d0..a929b45df 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -10,6 +10,7 @@ import { newLayoutNode, } from "@/layout/index"; import { getWebServerEndpoint, getWSServerEndpoint } from "@/util/endpoints"; +import { fetch } from "@/util/fetchutil"; import { produce } from "immer"; import * as jotai from "jotai"; import * as rxjs from "rxjs"; diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts index 7aa45fc66..49bf89018 100644 --- a/frontend/app/store/wos.ts +++ b/frontend/app/store/wos.ts @@ -5,6 +5,7 @@ import { sendRpcCommand } from "@/app/store/wshrpc"; import { getWebServerEndpoint } from "@/util/endpoints"; +import { fetch } from "@/util/fetchutil"; import * as jotai from "jotai"; import * as React from "react"; import { atoms, globalStore } from "./global"; diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 877685dbe..29609c47f 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -52,6 +52,7 @@ declare global { }; type ElectronApi = { + getAuthKey(): string; getIsDev(): boolean; getCursorPoint: () => Electron.Point; diff --git a/frontend/util/fetchutil.ts b/frontend/util/fetchutil.ts new file mode 100644 index 000000000..59dd68e02 --- /dev/null +++ b/frontend/util/fetchutil.ts @@ -0,0 +1,20 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Utility to abstract the fetch function so the Electron net module can be used when available. + +let net: Electron.Net; + +try { + import("electron").then(({ net: electronNet }) => (net = electronNet)); +} catch (e) { + // do nothing +} + +export function fetch(input: string | GlobalRequest | URL, init?: RequestInit): Promise { + if (net) { + return net.fetch(input.toString(), init); + } else { + return globalThis.fetch(input, init); + } +} diff --git a/pkg/authkey/authkey.go b/pkg/authkey/authkey.go new file mode 100644 index 000000000..26ec4a6f7 --- /dev/null +++ b/pkg/authkey/authkey.go @@ -0,0 +1,38 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package authkey + +import ( + "fmt" + "net/http" + "os" +) + +var authkey string + +const AuthKeyEnv = "AUTH_KEY" + +func SetAuthKeyFromEnv() error { + authkey = os.Getenv(AuthKeyEnv) + if authkey == "" { + return fmt.Errorf("no auth key found in environment variables") + } + os.Setenv(AuthKeyEnv, "") + return nil +} + +func GetAuthKey() string { + return authkey +} + +func ValidateIncomingRequest(r *http.Request) error { + reqAuthKey := r.Header.Get("X-AuthKey") + if reqAuthKey == "" { + return fmt.Errorf("no x-authkey header") + } + if reqAuthKey != GetAuthKey() { + return fmt.Errorf("x-authkey header is invalid") + } + return nil +} diff --git a/pkg/web/web.go b/pkg/web/web.go index d2335c351..eccd18938 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -21,6 +21,7 @@ import ( "github.com/google/uuid" "github.com/gorilla/handlers" "github.com/gorilla/mux" + "github.com/wavetermdev/thenextwave/pkg/authkey" "github.com/wavetermdev/thenextwave/pkg/filestore" "github.com/wavetermdev/thenextwave/pkg/service" "github.com/wavetermdev/thenextwave/pkg/telemetry" @@ -397,17 +398,13 @@ func WebFnWrap(opts WebFnOpts, fn WebFnType) WebFnType { w.Header().Set(CacheControlHeaderKey, CacheControlHeaderNoCache) } w.Header().Set("Access-Control-Expose-Headers", "X-ZoneFileInfo") - // reqAuthKey := r.Header.Get("X-AuthKey") - // if reqAuthKey == "" { - // w.WriteHeader(http.StatusInternalServerError) - // w.Write([]byte("no x-authkey header")) - // return - // } - // if reqAuthKey != scbase.WaveAuthKey { - // w.WriteHeader(http.StatusInternalServerError) - // w.Write([]byte("x-authkey header is invalid")) - // return - // } + err := authkey.ValidateIncomingRequest(r) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(fmt.Sprintf("error validating authkey: %v", err))) + log.Printf("error validating request: %v", err) + return + } fn(w, r) } } diff --git a/pkg/web/ws.go b/pkg/web/ws.go index fa335631d..267c9f275 100644 --- a/pkg/web/ws.go +++ b/pkg/web/ws.go @@ -16,6 +16,7 @@ import ( "github.com/google/uuid" "github.com/gorilla/mux" "github.com/gorilla/websocket" + "github.com/wavetermdev/thenextwave/pkg/authkey" "github.com/wavetermdev/thenextwave/pkg/eventbus" "github.com/wavetermdev/thenextwave/pkg/web/webcmd" "github.com/wavetermdev/thenextwave/pkg/wshrpc" @@ -244,6 +245,14 @@ func HandleWsInternal(w http.ResponseWriter, r *http.Request) error { if windowId == "" { return fmt.Errorf("windowid is required") } + + err := authkey.ValidateIncomingRequest(r) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(fmt.Sprintf("error validating authkey: %v", err))) + log.Printf("error validating request: %v", err) + return err + } conn, err := WebSocketUpgrader.Upgrade(w, r, nil) if err != nil { return fmt.Errorf("WebSocket Upgrade Failed: %v", err)