From 3892381b95cd7650db00f1c5d28743433546d2ec Mon Sep 17 00:00:00 2001 From: Lukas <80832688+0skater0@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:03:16 +0200 Subject: [PATCH] fix(desktop): apply linked server proxy headers and certificate exception to WebSockets (#1670) * fix(desktop): send linked server proxy headers on WebSocket upgrades isLinkedOrigin compared the request origin with the link's http(s) origin, so the terminal WebSocket (wss://host/...) never matched and its upgrade went out without the proxy headers and basic auth. Behind a forward-auth proxy that upgrade was redirected to the login and the terminal closed with "connection closed unexpectedly". ws and wss now count as http and https for the comparison, the same mapping the WebSocket handshake itself uses, with host and port still matched exactly. * fix(desktop): allow a linked server's self-signed certificate for its WebSockets isInvalidCertificateAllowedForUrl compared the URL with a local getOrigin copy, so wss://host never matched the link's https://host origin and an allowed self-signed certificate did not cover the terminal and tunnel sockets. It now uses linkedServer.isLinkedOrigin, which maps ws and wss the same way as the proxy headers, and the local copy is removed. --- electron/linked-server.cjs | 9 ++- electron/main.cjs | 10 +-- .../tests/electron/linked-certificate.test.ts | 65 +++++++++++++++++++ .../tests/electron/linked-server.test.ts | 42 ++++++++++++ 4 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 src/backend/tests/electron/linked-certificate.test.ts diff --git a/electron/linked-server.cjs b/electron/linked-server.cjs index 219df606d..a31a03250 100644 --- a/electron/linked-server.cjs +++ b/electron/linked-server.cjs @@ -6,9 +6,16 @@ let linked = null; +const WEBSOCKET_TO_HTTP = { "ws:": "http:", "wss:": "https:" }; + +// A WebSocket to the linked server (wss://host/...) counts as the link's +// https://host origin, so its upgrade carries the proxy headers too. function getOrigin(url) { try { - return new URL(url).origin; + const parsed = new URL(url); + const httpProtocol = WEBSOCKET_TO_HTTP[parsed.protocol]; + if (httpProtocol) parsed.protocol = httpProtocol; + return parsed.origin; } catch { return null; } diff --git a/electron/main.cjs b/electron/main.cjs index c28fa2939..ae3d49749 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -442,14 +442,6 @@ function isInsecureModeEnabled() { ); } -function getOrigin(url) { - try { - return new URL(url).origin; - } catch { - return null; - } -} - function isPrivateNetworkHost(hostname) { if ( hostname === "localhost" || @@ -493,7 +485,7 @@ function isInvalidCertificateAllowedForUrl(url) { // allows a self-signed certificate. const linked = linkedServer.getLinkedServer(); if (!linked?.allowInvalidCertificate) return false; - return getOrigin(url) === linked.origin; + return linkedServer.isLinkedOrigin(url); } function getTlsVerificationOptions(url) { diff --git a/src/backend/tests/electron/linked-certificate.test.ts b/src/backend/tests/electron/linked-certificate.test.ts new file mode 100644 index 000000000..87bab23f6 --- /dev/null +++ b/src/backend/tests/electron/linked-certificate.test.ts @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import vm from "node:vm"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const linkedServer = require("../../../../electron/linked-server.cjs") as { + setLinkedServer: (config: unknown) => void; +}; + +const source = fs.readFileSync( + new URL("../../../../electron/main.cjs", import.meta.url), + "utf8", +); +const tls = source.slice( + source.indexOf("const INSECURE_MODE_VALUES"), + source.indexOf("function httpFetch("), +); + +const ORIGIN = "https://termix.example"; +const TERMINAL = "wss://termix.example/plugin-ws/ssh-terminal/terminal"; + +function load(link: Record | null) { + linkedServer.setLinkedServer(link); + const context = vm.createContext({ + URL, + process: { env: {} }, + linkedServer, + }); + vm.runInContext(tls, context); + return context as unknown as { + isInvalidCertificateAllowedForUrl: (url: string) => boolean; + getWebSocketOptions: ( + url: string, + options?: Record, + ) => Record; + }; +} + +describe("linked server certificate exception", () => { + it("covers WebSockets to the linked server", () => { + const main = load({ origin: ORIGIN, allowInvalidCertificate: true }); + expect(main.isInvalidCertificateAllowedForUrl(TERMINAL)).toBe(true); + expect(main.getWebSocketOptions(TERMINAL, { headers: {} })).toEqual({ + headers: {}, + rejectUnauthorized: false, + }); + }); + + it.each([ + "ws://termix.example/relay", + "wss://termix.example:8443/relay", + "wss://elsewhere.example/relay", + ])("keeps verifying %s", (url) => { + const main = load({ origin: ORIGIN, allowInvalidCertificate: true }); + expect(main.isInvalidCertificateAllowedForUrl(url)).toBe(false); + }); + + it("keeps verifying when the link does not allow it", () => { + const main = load({ origin: ORIGIN, allowInvalidCertificate: false }); + expect(main.getWebSocketOptions(TERMINAL)).toEqual({ + rejectUnauthorized: true, + }); + }); +}); diff --git a/src/backend/tests/electron/linked-server.test.ts b/src/backend/tests/electron/linked-server.test.ts index 954188205..fe97739d4 100644 --- a/src/backend/tests/electron/linked-server.test.ts +++ b/src/backend/tests/electron/linked-server.test.ts @@ -39,6 +39,48 @@ describe("linked server in Electron main", () => { ).toEqual({ Accept: "x" }); }); + it("treats WebSocket upgrades to the linked server as the linked origin", () => { + linked.setLinkedServer({ origin: ORIGIN }); + expect(linked.isLinkedOrigin(`${ORIGIN}/host/status`)).toBe(true); + expect( + linked.isLinkedOrigin("wss://termix.example/plugin-ws/ssh-terminal"), + ).toBe(true); + expect(linked.isLinkedOrigin("wss://termix.example:443/")).toBe(true); + expect(linked.isLinkedOrigin("ws://termix.example/")).toBe(false); + expect(linked.isLinkedOrigin("wss://termix.example:8443/")).toBe(false); + expect(linked.isLinkedOrigin("wss://other.example/")).toBe(false); + expect(linked.isLinkedOrigin("not a url")).toBe(false); + + linked.setLinkedServer({ origin: "http://termix.example:8080" }); + expect(linked.isLinkedOrigin("ws://termix.example:8080/ws")).toBe(true); + expect(linked.isLinkedOrigin("wss://termix.example:8080/ws")).toBe(false); + expect(linked.isLinkedOrigin("ws://termix.example/ws")).toBe(false); + }); + + it("adds proxy headers and basic auth to the terminal WebSocket", () => { + linked.setLinkedServer({ + origin: ORIGIN, + headers: [{ name: "CF-Access-Client-Id", value: "id" }], + basicAuth: { username: "gate", password: "pw" }, + }); + expect( + linked.applyLinkHeaders( + "wss://termix.example/plugin-ws/ssh-terminal/terminal", + { Upgrade: "websocket" }, + ), + ).toEqual({ + Upgrade: "websocket", + "CF-Access-Client-Id": "id", + Authorization: `Basic ${Buffer.from("gate:pw").toString("base64")}`, + }); + expect( + linked.answerLogin({}, "wss://termix.example/plugin-ws/ssh-terminal"), + ).toEqual({ username: "gate", password: "pw" }); + expect( + linked.applyLinkHeaders("wss://other.example/", { Upgrade: "websocket" }), + ).toEqual({ Upgrade: "websocket" }); + }); + it("moves a Termix bearer into the jwt cookie when basic auth is in front", () => { linked.setLinkedServer({ origin: ORIGIN,