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.
This commit is contained in:
Lukas authored and GitHub committed 2026-10-17 19:03:16 -05:00
1 parent 4a9c434cd1
commit 3892381b95
4 files changed
+116 -10

No files matched your search

+8 -1
View File
@@ -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;
}
+1 -9
View File
@@ -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) {
@@ -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<string, unknown> | 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<string, unknown>,
) => Record<string, unknown>;
};
}
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,
});
});
});
@@ -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,