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,