mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
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:
1 parent
4a9c434cd1
commit
3892381b95
4 files changed
+116
-10
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user