From c09a57370ebfef5d6ff93fff10f1e782255d3af5 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 8 Nov 2021 19:02:10 -0500 Subject: [PATCH] Block shell websocket connections from non-lens renderers (#4285) * Block shell websocket connections from non-lens renderers Signed-off-by: Sebastian Malton * Add IPC based authentication tokens Signed-off-by: Sebastian Malton * Fix race condition on tab start Signed-off-by: Sebastian Malton --- src/main/index.ts | 5 +- src/main/lens-proxy.ts | 2 +- src/main/proxy-functions/shell-api-request.ts | 81 +++++++++++++++---- src/main/shell-session/node-shell-session.ts | 2 +- src/main/shell-session/shell-session.ts | 2 +- src/renderer/api/terminal-api.ts | 37 +++++---- 6 files changed, 89 insertions(+), 40 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 439d2e6471..fc443c3896 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -63,7 +63,7 @@ import { ensureDir } from "fs-extra"; import { Router } from "./router"; import { initMenu } from "./menu"; import { initTray } from "./tray"; -import { kubeApiRequest, shellApiRequest } from "./proxy-functions"; +import { kubeApiRequest, shellApiRequest, ShellRequestAuthenticator } from "./proxy-functions"; import { AppPaths } from "../common/app-paths"; injectSystemCAs(); @@ -122,7 +122,7 @@ app.on("second-instance", (event, argv) => { WindowManager.getInstance(false)?.ensureMainWindow(); }); -app.on("ready", async () => { +app.on("ready", async () => { logger.info(`🚀 Starting ${productName} from "${AppPaths.get("exe")}"`); logger.info("🐚 Syncing shell environment"); await shellSync(); @@ -134,6 +134,7 @@ app.on("ready", async () => { registerFileProtocol("static", __static); PrometheusProviderRegistry.createInstance(); + ShellRequestAuthenticator.createInstance().init(); initializers.initPrometheusProviderRegistry(); /** diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index a3bee528cd..d22981ddf4 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -82,7 +82,7 @@ export class LensProxy extends Singleton { const reqHandler = isInternal ? shellApiRequest : kubeApiRequest; (async () => reqHandler({ req, socket, head }))() - .catch(error => logger.error(logger.error(`[LENS-PROXY]: failed to handle proxy upgrade: ${error}`))); + .catch(error => logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error)); }); } diff --git a/src/main/proxy-functions/shell-api-request.ts b/src/main/proxy-functions/shell-api-request.ts index 3edaecff81..db88336a2a 100644 --- a/src/main/proxy-functions/shell-api-request.ts +++ b/src/main/proxy-functions/shell-api-request.ts @@ -19,29 +19,78 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type http from "http"; -import url from "url"; import logger from "../logger"; -import * as WebSocket from "ws"; +import { Server as WebSocketServer } from "ws"; import { NodeShellSession, LocalShellSession } from "../shell-session"; import type { ProxyApiRequestArgs } from "./types"; import { ClusterManager } from "../cluster-manager"; +import URLParse from "url-parse"; +import { ExtendedMap, Singleton } from "../../common/utils"; +import type { ClusterId } from "../../common/cluster-types"; +import { ipcMainHandle } from "../../common/ipc"; +import * as uuid from "uuid"; -export function shellApiRequest({ req, socket, head }: ProxyApiRequestArgs) { - const ws = new WebSocket.Server({ noServer: true }); +export class ShellRequestAuthenticator extends Singleton { + private tokens = new ExtendedMap>(); - ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => { - const cluster = ClusterManager.getInstance().getClusterForRequest(req); - const nodeParam = url.parse(req.url, true).query["node"]?.toString(); - const shell = nodeParam - ? new NodeShellSession(socket, cluster, nodeParam) - : new LocalShellSession(socket, cluster); + init() { + ipcMainHandle("cluster:shell-api", (event, clusterId, tabId) => { + const authToken = uuid.v4(); + + this.tokens + .getOrInsert(clusterId, () => new Map()) + .set(tabId, authToken); + + return authToken; + }); + } + + /** + * Authenticates a single use token for creating a new shell + * @param clusterId The `ClusterId` for the shell + * @param tabId The ID for the shell + * @param token The value that is being presented as a one time authentication token + * @returns `true` if `token` was valid, false otherwise + */ + authenticate(clusterId: ClusterId, tabId: string, token: string): boolean { + const clusterTokens = this.tokens.get(clusterId); + + if (!clusterTokens) { + return false; + } + + const authToken = clusterTokens.get(tabId); + + // need both conditions to prevent `undefined === undefined` being true here + if (typeof authToken === "string" && authToken === token) { + // remove the token because it is a single use token + clusterTokens.delete(tabId); + + return true; + } + + return false; + } +} + +export function shellApiRequest({ req, socket, head }: ProxyApiRequestArgs): void { + const cluster = ClusterManager.getInstance().getClusterForRequest(req); + const { query: { node, shellToken, id: tabId }} = new URLParse(req.url, true); + + if (!cluster || !ShellRequestAuthenticator.getInstance().authenticate(cluster.id, tabId, shellToken)) { + socket.write("Invalid shell request"); + + return void socket.end(); + } + + const ws = new WebSocketServer({ noServer: true }); + + ws.handleUpgrade(req, socket, head, (webSocket) => { + const shell = node + ? new NodeShellSession(webSocket, cluster, node) + : new LocalShellSession(webSocket, cluster); shell.open() - .catch(error => logger.error(`[SHELL-SESSION]: failed to open: ${error}`, { error })); - })); - - ws.handleUpgrade(req, socket, head, (con) => { - ws.emit("connection", con, req); + .catch(error => logger.error(`[SHELL-SESSION]: failed to open a ${node ? "node" : "local"} shell`, error)); }); } diff --git a/src/main/shell-session/node-shell-session.ts b/src/main/shell-session/node-shell-session.ts index 494137de7d..db0bb89bd3 100644 --- a/src/main/shell-session/node-shell-session.ts +++ b/src/main/shell-session/node-shell-session.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type * as WebSocket from "ws"; +import type WebSocket from "ws"; import { v4 as uuid } from "uuid"; import * as k8s from "@kubernetes/client-node"; import type { KubeConfig } from "@kubernetes/client-node"; diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts index 987a00afdd..ba05a22dc5 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-session/shell-session.ts @@ -22,7 +22,7 @@ import fse from "fs-extra"; import type { Cluster } from "../cluster"; import { Kubectl } from "../kubectl"; -import type * as WebSocket from "ws"; +import type WebSocket from "ws"; import { shellEnv } from "../utils/shell-env"; import { app } from "electron"; import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; diff --git a/src/renderer/api/terminal-api.ts b/src/renderer/api/terminal-api.ts index 1a66b25f46..a1533430db 100644 --- a/src/renderer/api/terminal-api.ts +++ b/src/renderer/api/terminal-api.ts @@ -19,13 +19,13 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { boundMethod, base64, EventEmitter } from "../utils"; +import { boundMethod, base64, EventEmitter, getHostedClusterId } from "../utils"; import { WebSocketApi } from "./websocket-api"; import isEqual from "lodash/isEqual"; import { isDevelopment } from "../../common/vars"; import url from "url"; import { makeObservable, observable } from "mobx"; -import type { ParsedUrlQueryInput } from "querystring"; +import { ipcRenderer } from "electron"; export enum TerminalChannels { STDIN = 0, @@ -50,7 +50,7 @@ enum TerminalColor { export type TerminalApiQuery = Record & { id: string; node?: string; - type?: string | "node"; + type?: string; }; export class TerminalApi extends WebSocketApi { @@ -58,9 +58,8 @@ export class TerminalApi extends WebSocketApi { public onReady = new EventEmitter<[]>(); @observable public isReady = false; - public readonly url: string; - constructor(protected options: TerminalApiQuery) { + constructor(protected query: TerminalApiQuery) { super({ logging: isDevelopment, flushOnOpen: false, @@ -68,30 +67,30 @@ export class TerminalApi extends WebSocketApi { }); makeObservable(this); - const { hostname, protocol, port } = location; - const query: ParsedUrlQueryInput = { - id: options.id, - }; - - if (options.node) { - query.node = options.node; - query.type = options.type || "node"; + if (query.node) { + query.type ||= "node"; } + } - this.url = url.format({ + async connect() { + this.emitStatus("Connecting ..."); + + const shellToken = await ipcRenderer.invoke("cluster:shell-api", getHostedClusterId(), this.query.id); + const { hostname, protocol, port } = location; + const socketUrl = url.format({ protocol: protocol.includes("https") ? "wss" : "ws", hostname, port, pathname: "/api", - query, + query: { + ...this.query, + shellToken, + }, slashes: true, }); - } - connect() { - this.emitStatus("Connecting ..."); this.onData.addListener(this._onReady, { prepend: true }); - super.connect(this.url); + super.connect(socketUrl); } destroy() {