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.
This commit is contained in:
Evan Simkowitz 2024-08-21 15:04:39 -07:00 committed by GitHub
parent 23261a7a98
commit e527e2ab77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 206 additions and 75 deletions

View File

@ -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

25
emain/authkey.ts Normal file
View File

@ -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 });
});
}

View File

@ -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<void> };
@ -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<boolean> {
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<boolean> {
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);

69
emain/platform.ts Normal file
View File

@ -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,
};

View File

@ -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)),

View File

@ -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";

View File

@ -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";

View File

@ -52,6 +52,7 @@ declare global {
};
type ElectronApi = {
getAuthKey(): string;
getIsDev(): boolean;
getCursorPoint: () => Electron.Point;

View File

@ -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<Response> {
if (net) {
return net.fetch(input.toString(), init);
} else {
return globalThis.fetch(input, init);
}
}

38
pkg/authkey/authkey.go Normal file
View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)