mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
fix: harden application trust boundaries (#1317)
This commit is contained in:
@@ -313,6 +313,7 @@ function ConsoleTerminalInner({
|
||||
window.location.port === "");
|
||||
|
||||
let baseWsUrl: string;
|
||||
let wsProtocols: string[] = [];
|
||||
if (isDev) {
|
||||
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`;
|
||||
} else if (isElectronApp) {
|
||||
@@ -331,12 +332,13 @@ function ConsoleTerminalInner({
|
||||
toast.error(t("errors.remoteServerRequired"));
|
||||
return;
|
||||
}
|
||||
baseWsUrl = resolvedUrl;
|
||||
baseWsUrl = resolvedUrl.url;
|
||||
wsProtocols = resolvedUrl.protocols;
|
||||
} else {
|
||||
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(baseWsUrl);
|
||||
const ws = new WebSocket(baseWsUrl, wsProtocols);
|
||||
|
||||
ws.onopen = () => {
|
||||
const cols = terminal.cols || 80;
|
||||
|
||||
@@ -250,17 +250,18 @@ export const GuacamoleDisplay = forwardRef<
|
||||
const origin = await resolveConnectionOrigin({
|
||||
connectionType: connectionProtocol,
|
||||
});
|
||||
wsBase = await buildOriginWsUrl({
|
||||
const target = await buildOriginWsUrl({
|
||||
origin,
|
||||
localPort: 30008,
|
||||
localPath: "/guacamole/websocket/",
|
||||
remotePath: "/guacamole/websocket/",
|
||||
includeJwt: false,
|
||||
});
|
||||
if (!wsBase) {
|
||||
if (!target) {
|
||||
onError?.(t("errors.remoteServerRequired"));
|
||||
return null;
|
||||
}
|
||||
wsBase = target.url;
|
||||
} else {
|
||||
wsBase = buildGuacamoleWebSocketBaseUrl({
|
||||
isDev,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { websocketAuthProtocols } from "@/lib/ws-auth";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
|
||||
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
|
||||
@@ -102,9 +103,7 @@ export const Serial = forwardRef<SerialHandle, SerialProps>(function Serial(
|
||||
const buildWsUrl = useCallback(() => {
|
||||
// Serial is always local -- the device is physically attached to this
|
||||
// desktop machine, so it never routes through a remote server.
|
||||
const token = localStorage.getItem("jwt");
|
||||
const base = "ws://127.0.0.1:30011";
|
||||
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
||||
return "ws://127.0.0.1:30011";
|
||||
}, []);
|
||||
|
||||
const disconnectWs = useCallback(() => {
|
||||
@@ -124,7 +123,10 @@ export const Serial = forwardRef<SerialHandle, SerialProps>(function Serial(
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
const ws = new WebSocket(
|
||||
url,
|
||||
websocketAuthProtocols(localStorage.getItem("jwt")),
|
||||
);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
|
||||
@@ -1201,6 +1201,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
window.location.port === "");
|
||||
|
||||
let baseWsUrl: string;
|
||||
let wsProtocols: string[] = [];
|
||||
|
||||
if (isDev) {
|
||||
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
|
||||
@@ -1223,7 +1224,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
baseWsUrl = resolvedUrl;
|
||||
baseWsUrl = resolvedUrl.url;
|
||||
wsProtocols = resolvedUrl.protocols;
|
||||
} else {
|
||||
baseWsUrl = `${getBasePath()}/ssh/websocket/`;
|
||||
}
|
||||
@@ -1246,7 +1248,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(baseWsUrl);
|
||||
const ws = new WebSocket(baseWsUrl, wsProtocols);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
updateConnectionError(null);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { websocketAuthProtocols } from "@/lib/ws-auth";
|
||||
|
||||
export type ConnectionOrigin = "local" | "remote";
|
||||
|
||||
@@ -75,6 +76,11 @@ async function getRemoteConnectionTarget(): Promise<RemoteConnectionTarget | nul
|
||||
* remote server is connected -- callers must show a blocking message
|
||||
* rather than attempting to connect.
|
||||
*/
|
||||
export interface WebSocketConnectionTarget {
|
||||
url: string;
|
||||
protocols: string[];
|
||||
}
|
||||
|
||||
export async function buildOriginWsUrl({
|
||||
origin,
|
||||
localPort,
|
||||
@@ -87,14 +93,13 @@ export async function buildOriginWsUrl({
|
||||
localPath: string;
|
||||
remotePath: string;
|
||||
includeJwt?: boolean;
|
||||
}): Promise<string | null> {
|
||||
}): Promise<WebSocketConnectionTarget | null> {
|
||||
if (origin === "local") {
|
||||
let url = `ws://127.0.0.1:${localPort}${localPath}`;
|
||||
if (includeJwt) {
|
||||
const token = localStorage.getItem("jwt");
|
||||
if (token) url += `?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
return url;
|
||||
const token = includeJwt ? localStorage.getItem("jwt") : null;
|
||||
return {
|
||||
url: `ws://127.0.0.1:${localPort}${localPath}`,
|
||||
protocols: websocketAuthProtocols(token),
|
||||
};
|
||||
}
|
||||
|
||||
const remote = await getRemoteConnectionTarget();
|
||||
@@ -106,9 +111,8 @@ export async function buildOriginWsUrl({
|
||||
const wsHost = remote.serverUrl
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "");
|
||||
let url = `${wsProtocol}${wsHost}${remotePath}`;
|
||||
if (includeJwt && remote.jwt) {
|
||||
url += `?token=${encodeURIComponent(remote.jwt)}`;
|
||||
}
|
||||
return url;
|
||||
return {
|
||||
url: `${wsProtocol}${wsHost}${remotePath}`,
|
||||
protocols: websocketAuthProtocols(includeJwt ? remote.jwt : null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
const JWT_PROTOCOL_PREFIX = "termix.jwt.";
|
||||
|
||||
export function websocketAuthProtocols(token: string | null): string[] {
|
||||
return token ? [`${JWT_PROTOCOL_PREFIX}${token}`] : [];
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw, Usb, TriangleAlert } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { websocketAuthProtocols } from "@/lib/ws-auth";
|
||||
import type { SerialConfig } from "@/types/ui-types";
|
||||
|
||||
const BAUD_RATES = [
|
||||
@@ -34,9 +35,7 @@ export function SerialPanel({ onConnect }: SerialPanelProps) {
|
||||
const buildWsUrl = () => {
|
||||
// Serial is always local -- the device is physically attached to this
|
||||
// desktop machine, so it never routes through a remote server.
|
||||
const token = localStorage.getItem("jwt");
|
||||
const base = "ws://127.0.0.1:30011";
|
||||
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
||||
return "ws://127.0.0.1:30011";
|
||||
};
|
||||
|
||||
const refreshPorts = useCallback(() => {
|
||||
@@ -48,7 +47,10 @@ export function SerialPanel({ onConnect }: SerialPanelProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
const ws = new WebSocket(
|
||||
url,
|
||||
websocketAuthProtocols(localStorage.getItem("jwt")),
|
||||
);
|
||||
ws.onopen = () => ws.send(JSON.stringify({ type: "list_ports" }));
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
|
||||
@@ -116,18 +116,21 @@ describe("buildOriginWsUrl", () => {
|
||||
|
||||
it("carries the local JWT by default", async () => {
|
||||
// Every interactive channel on the embedded backend relies on this.
|
||||
const url = await buildOriginWsUrl({
|
||||
const target = await buildOriginWsUrl({
|
||||
origin: "local",
|
||||
localPort: 30009,
|
||||
localPath: "/docker/console/",
|
||||
remotePath: "/docker/console/",
|
||||
});
|
||||
|
||||
expect(url).toBe("ws://127.0.0.1:30009/docker/console/?token=local-jwt");
|
||||
expect(target).toEqual({
|
||||
url: "ws://127.0.0.1:30009/docker/console/",
|
||||
protocols: ["termix.jwt.local-jwt"],
|
||||
});
|
||||
});
|
||||
|
||||
it("omits it only when a caller asks", async () => {
|
||||
const url = await buildOriginWsUrl({
|
||||
const target = await buildOriginWsUrl({
|
||||
origin: "local",
|
||||
localPort: 30009,
|
||||
localPath: "/docker/console/",
|
||||
@@ -135,7 +138,10 @@ describe("buildOriginWsUrl", () => {
|
||||
includeJwt: false,
|
||||
});
|
||||
|
||||
expect(url).toBe("ws://127.0.0.1:30009/docker/console/");
|
||||
expect(target).toEqual({
|
||||
url: "ws://127.0.0.1:30009/docker/console/",
|
||||
protocols: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not duplicate the Guacamole token on remote connections", async () => {
|
||||
@@ -149,7 +155,7 @@ describe("buildOriginWsUrl", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const url = await buildOriginWsUrl({
|
||||
const target = await buildOriginWsUrl({
|
||||
origin: "remote",
|
||||
localPort: 30008,
|
||||
localPath: "/guacamole/websocket/",
|
||||
@@ -157,19 +163,25 @@ describe("buildOriginWsUrl", () => {
|
||||
includeJwt: false,
|
||||
});
|
||||
|
||||
expect(url).toBe("wss://termix.example/guacamole/websocket/");
|
||||
expect(target).toEqual({
|
||||
url: "wss://termix.example/guacamole/websocket/",
|
||||
protocols: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the URL alone when there is no token stored", async () => {
|
||||
delete store.jwt;
|
||||
|
||||
const url = await buildOriginWsUrl({
|
||||
const target = await buildOriginWsUrl({
|
||||
origin: "local",
|
||||
localPort: 30002,
|
||||
localPath: "",
|
||||
remotePath: "/ssh/websocket/",
|
||||
});
|
||||
|
||||
expect(url).toBe("ws://127.0.0.1:30002");
|
||||
expect(target).toEqual({
|
||||
url: "ws://127.0.0.1:30002",
|
||||
protocols: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { websocketAuthProtocols } from "@/lib/ws-auth";
|
||||
|
||||
describe("websocketAuthProtocols", () => {
|
||||
it("moves a JWT into the WebSocket protocol header", () => {
|
||||
expect(websocketAuthProtocols("header.payload.sig")).toEqual([
|
||||
"termix.jwt.header.payload.sig",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not advertise an authentication protocol without a token", () => {
|
||||
expect(websocketAuthProtocols(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user