fix: harden application trust boundaries (#1317)

This commit is contained in:
ZacharyZcR
2026-08-24 07:55:17 +08:00
committed by GitHub
parent 30d72554fc
commit 2de9bb236b
31 changed files with 287 additions and 132 deletions
@@ -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,
+6 -4
View File
@@ -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 = () => {
+4 -2
View File
@@ -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);
+16 -12
View File
@@ -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),
};
}
+5
View File
@@ -0,0 +1,5 @@
const JWT_PROTOCOL_PREFIX = "termix.jwt.";
export function websocketAuthProtocols(token: string | null): string[] {
return token ? [`${JWT_PROTOCOL_PREFIX}${token}`] : [];
}
+6 -4
View File
@@ -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 {
+20 -8
View File
@@ -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: [],
});
});
});
+14
View File
@@ -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([]);
});
});