mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
fix: harden the plugin runtime after the 2.9.0 security review
This commit is contained in:
1 parent
2961297989
commit
c6a44b7ff3
69 files changed
+4613
-312
No files matched your search
@@ -17,6 +17,7 @@
|
||||
"capabilities": [
|
||||
"hosts:read",
|
||||
"credentials:use",
|
||||
"credentials:read",
|
||||
"ssh:connect",
|
||||
"db:own",
|
||||
"network:serve",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"capabilities": [
|
||||
"hosts:read",
|
||||
"credentials:use",
|
||||
"credentials:read",
|
||||
"ssh:connect",
|
||||
"db:own",
|
||||
"network:serve",
|
||||
|
||||
@@ -300,9 +300,8 @@ export class MetricsPoller {
|
||||
this.initialRequested.add(host.id);
|
||||
void this.state.initialLimiter
|
||||
.run(async () => {
|
||||
let status: PluginHostStatusEntry | null = this.ctx.hosts.status.get(
|
||||
host.id,
|
||||
);
|
||||
let status: PluginHostStatusEntry | null =
|
||||
await this.ctx.hosts.status.get(host.id);
|
||||
if (!status) status = await this.ctx.hosts.status.check(host.id);
|
||||
if (!canStartInitialMetrics(status?.status, this.hasViewers(host.id))) {
|
||||
return;
|
||||
|
||||
@@ -264,6 +264,23 @@ export function createGuacamoleServer(
|
||||
|
||||
return {
|
||||
handleUpgrade(request, socket, head) {
|
||||
// The route is public, so the token is the only credential: refuse
|
||||
// anything forged, edited or expired before guacamole-lite reads it.
|
||||
const token = new URL(
|
||||
request.url ?? "/",
|
||||
"http://localhost",
|
||||
).searchParams.get("token");
|
||||
if (!token || !deps.tokens.verifyToken(token)) {
|
||||
try {
|
||||
socket.write(
|
||||
"HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n",
|
||||
);
|
||||
} catch {
|
||||
// The peer may already be gone.
|
||||
}
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const wss = (
|
||||
server as unknown as {
|
||||
webSocketServer?: {
|
||||
|
||||
@@ -3,7 +3,10 @@ import net from "net";
|
||||
import path from "path";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { GuacamoleTokenService } from "./token-service.js";
|
||||
import {
|
||||
isServerOwnedSetting,
|
||||
type GuacamoleTokenService,
|
||||
} from "./token-service.js";
|
||||
import type { GuacdOptions } from "./guacd-config.js";
|
||||
import type { RemoteSessions } from "./sessions.js";
|
||||
import type { RecordingsWriter } from "./guacamole-server.js";
|
||||
@@ -188,6 +191,9 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
|
||||
}
|
||||
const options: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
// A quick connect names a host, never where guacd is or what paths
|
||||
// it writes to: those would let any user aim guacd anywhere.
|
||||
if (isServerOwnedSetting(key)) continue;
|
||||
if (value !== "auto") options[key] = value;
|
||||
}
|
||||
const host = String(hostname);
|
||||
@@ -199,6 +205,7 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
|
||||
for (const [key, value] of Object.entries(
|
||||
await readUserDefaults(ctx, userId),
|
||||
)) {
|
||||
if (isServerOwnedSetting(key)) continue;
|
||||
if (options[key] === undefined) options[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface GuacamoleToken {
|
||||
connection: GuacamoleConnectionSettings;
|
||||
recording?: GuacamoleRecordingMetadata;
|
||||
termixMeta?: TermixGuacMeta;
|
||||
/** When the token stops opening displays, in ms since the epoch. */
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export interface GuacamoleRecordingMetadata {
|
||||
@@ -63,6 +65,26 @@ export interface GuacamoleRecordingMetadata {
|
||||
const CIPHER = "aes-256-cbc";
|
||||
const KEY_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* How long a token opens a display for. Short, because a token for a saved
|
||||
* host carries its password and ends up in the display URL.
|
||||
*/
|
||||
export const TOKEN_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Settings the server decides, never the caller: where guacd is, and every
|
||||
* path guacd writes or reads on its own filesystem.
|
||||
*/
|
||||
export function isServerOwnedSetting(key: string): boolean {
|
||||
return (
|
||||
key.startsWith("guacd") ||
|
||||
key.endsWith("-path") ||
|
||||
key.startsWith("create-") ||
|
||||
key.startsWith("recording-") ||
|
||||
key.startsWith("typescript-")
|
||||
);
|
||||
}
|
||||
|
||||
const silentLogger: TokenLogger = { warn: () => {}, error: () => {} };
|
||||
|
||||
/**
|
||||
@@ -71,9 +93,17 @@ const silentLogger: TokenLogger = { warn: () => {}, error: () => {} };
|
||||
*/
|
||||
export class GuacamoleTokenService {
|
||||
private encryptionKey: Buffer;
|
||||
private macKey: Buffer;
|
||||
|
||||
constructor(private readonly log: TokenLogger = silentLogger) {
|
||||
constructor(
|
||||
private readonly log: TokenLogger = silentLogger,
|
||||
private readonly now: () => number = Date.now,
|
||||
) {
|
||||
this.encryptionKey = this.initializeKey();
|
||||
this.macKey = crypto
|
||||
.createHmac("sha256", this.encryptionKey)
|
||||
.update("termix-guacamole-token-mac")
|
||||
.digest();
|
||||
}
|
||||
|
||||
private initializeKey(): Buffer {
|
||||
@@ -105,12 +135,17 @@ export class GuacamoleTokenService {
|
||||
return this.encryptionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* guacamole-lite only knows AES-CBC, which does not notice tampering, so
|
||||
* the ciphertext also carries an HMAC that verifyToken checks before
|
||||
* guacamole-lite ever sees the token.
|
||||
*/
|
||||
encryptToken(tokenObject: GuacamoleToken): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(CIPHER, this.encryptionKey, iv);
|
||||
|
||||
let encrypted = cipher.update(
|
||||
JSON.stringify(tokenObject),
|
||||
JSON.stringify({ ...tokenObject, exp: this.now() + TOKEN_TTL_MS }),
|
||||
"utf8",
|
||||
"base64",
|
||||
);
|
||||
@@ -119,11 +154,50 @@ export class GuacamoleTokenService {
|
||||
const data = {
|
||||
iv: iv.toString("base64"),
|
||||
value: encrypted,
|
||||
mac: this.mac(iv.toString("base64"), encrypted),
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(data)).toString("base64");
|
||||
}
|
||||
|
||||
private mac(iv: string, value: string): string {
|
||||
return crypto
|
||||
.createHmac("sha256", this.macKey)
|
||||
.update(`${iv}.${value}`)
|
||||
.digest("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* The token if it is one this server minted, untouched and not expired,
|
||||
* else null. The gate in front of guacamole-lite.
|
||||
*/
|
||||
verifyToken(token: string): GuacamoleToken | null {
|
||||
let data: { iv?: unknown; value?: unknown; mac?: unknown };
|
||||
try {
|
||||
data = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof data?.iv !== "string" ||
|
||||
typeof data.value !== "string" ||
|
||||
typeof data.mac !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const expected = Buffer.from(this.mac(data.iv, data.value), "base64");
|
||||
const given = Buffer.from(data.mac, "base64");
|
||||
if (
|
||||
expected.length !== given.length ||
|
||||
!crypto.timingSafeEqual(expected, given)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const decoded = this.decryptToken(token);
|
||||
if (!decoded || typeof decoded.exp !== "number") return null;
|
||||
return decoded.exp > this.now() ? decoded : null;
|
||||
}
|
||||
|
||||
decryptToken(token: string): GuacamoleToken | null {
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
|
||||
|
||||
@@ -261,6 +261,26 @@ describe("the rest of the routes", () => {
|
||||
).toMatchObject({ hostname: "10.0.0.9", port: 3389, "color-depth": 24 });
|
||||
});
|
||||
|
||||
it("ignores guacd and path settings a quick connect tries to set", async () => {
|
||||
server = await start();
|
||||
const response = await server.request("POST", "/token", {
|
||||
type: "rdp",
|
||||
hostname: "10.0.0.9",
|
||||
guacdHost: "attacker.example",
|
||||
guacdPort: 4822,
|
||||
"drive-path": "/etc",
|
||||
"recording-path": "/tmp/x",
|
||||
width: 800,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const token = tokens.decryptToken(response.body.token);
|
||||
expect(token?.connection.guacdHost).toBeUndefined();
|
||||
expect(token?.connection.guacdPort).toBeUndefined();
|
||||
expect(token?.connection.settings["drive-path"]).toBeUndefined();
|
||||
expect(token?.connection.settings["recording-path"]).toBeUndefined();
|
||||
expect(token?.connection.settings.width).toBe(800);
|
||||
});
|
||||
|
||||
it("offers native RDP only in the desktop app", async () => {
|
||||
server = await start();
|
||||
expect((await server.request("GET", "/native-rdp")).body).toEqual({
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* A display token is the only credential on the public /display socket, so
|
||||
* it has to be one this server minted, unedited and recent.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
GuacamoleTokenService,
|
||||
TOKEN_TTL_MS,
|
||||
isServerOwnedSetting,
|
||||
} from "../../src/backend/token-service.js";
|
||||
|
||||
process.env.JWT_SECRET = "remote-desktop-test-secret";
|
||||
|
||||
function decode(token: string) {
|
||||
return JSON.parse(Buffer.from(token, "base64").toString("utf8")) as {
|
||||
iv: string;
|
||||
value: string;
|
||||
mac: string;
|
||||
};
|
||||
}
|
||||
|
||||
function encode(data: unknown): string {
|
||||
return Buffer.from(JSON.stringify(data)).toString("base64");
|
||||
}
|
||||
|
||||
describe("verifyToken", () => {
|
||||
it("accepts a fresh token it minted", () => {
|
||||
const tokens = new GuacamoleTokenService();
|
||||
const token = tokens.createRdpToken("10.0.0.7", "admin", "secret");
|
||||
expect(tokens.verifyToken(token)?.connection.settings.hostname).toBe(
|
||||
"10.0.0.7",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an edited ciphertext or IV", () => {
|
||||
const tokens = new GuacamoleTokenService();
|
||||
const data = decode(tokens.createRdpToken("10.0.0.7", "admin", "secret"));
|
||||
const iv = Buffer.from(data.iv, "base64");
|
||||
iv[0] ^= 0xff;
|
||||
expect(
|
||||
tokens.verifyToken(encode({ ...data, iv: iv.toString("base64") })),
|
||||
).toBeNull();
|
||||
expect(
|
||||
tokens.verifyToken(encode({ ...data, value: `A${data.value.slice(1)}` })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a token with no MAC, as guacamole-lite alone would accept", () => {
|
||||
const tokens = new GuacamoleTokenService();
|
||||
const { iv, value } = decode(tokens.createRdpToken("h", "u", "p"));
|
||||
expect(tokens.verifyToken(encode({ iv, value }))).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a token once it is older than the TTL", () => {
|
||||
let now = 1_000_000;
|
||||
const tokens = new GuacamoleTokenService(undefined, () => now);
|
||||
const token = tokens.createJoinToken("session-1", true);
|
||||
now += TOKEN_TTL_MS - 1;
|
||||
expect(tokens.verifyToken(token)).not.toBeNull();
|
||||
now += 2;
|
||||
expect(tokens.verifyToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses garbage", () => {
|
||||
const tokens = new GuacamoleTokenService();
|
||||
expect(tokens.verifyToken("not-a-token")).toBeNull();
|
||||
expect(tokens.verifyToken(encode({ iv: 1 }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isServerOwnedSetting", () => {
|
||||
it.each([
|
||||
"guacdHost",
|
||||
"guacdPort",
|
||||
"drive-path",
|
||||
"create-drive-path",
|
||||
"recording-path",
|
||||
"recording-name",
|
||||
"create-recording-path",
|
||||
"typescript-path",
|
||||
"sftp-private-key-path",
|
||||
])("keeps %s for the server", (key) => {
|
||||
expect(isServerOwnedSetting(key)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["width", "security", "ignore-cert", "color-depth", "enable-drive"])(
|
||||
"lets a caller set %s",
|
||||
(key) => {
|
||||
expect(isServerOwnedSetting(key)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -128,6 +128,10 @@ export function createSessionGuests(deps: SharingDeps): SessionGuestsV1 {
|
||||
return {
|
||||
async resolve({ shareToken, roomGuestToken, clientIp }) {
|
||||
if (shareToken) {
|
||||
// Guessing link tokens is limited the same way room tokens are.
|
||||
if (guestLimiter.isLimited(clientIp)) {
|
||||
return { ok: false, reason: "Too many requests" };
|
||||
}
|
||||
const share = await shares.findByLinkToken(shareToken);
|
||||
if (!share || share.shareType !== "link") {
|
||||
return { ok: false, reason: "Invalid or expired share link" };
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"capabilities": [
|
||||
"hosts:read",
|
||||
"credentials:use",
|
||||
"credentials:read",
|
||||
"ssh:connect",
|
||||
"network:serve",
|
||||
"db:own",
|
||||
|
||||
@@ -214,7 +214,10 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
||||
const participant = liveSession
|
||||
? sessionManager.getParticipantForWs(liveSession, ws)
|
||||
: null;
|
||||
if (!isMessageAllowedForParticipant(participant, type)) {
|
||||
// A guest is only ever a participant. Once it has left (or was never
|
||||
// added) it may send nothing, or a read-only guest could "disconnect"
|
||||
// and then type into the owner's shell as nobody in particular.
|
||||
if (!participant || !isMessageAllowedForParticipant(participant, type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -238,6 +241,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
||||
break;
|
||||
case "disconnect":
|
||||
sessionManager.removeParticipant(currentSessionId, ws);
|
||||
ws.close(1000, "Left the session");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* A share-link guest only ever acts as a participant. Once it has left the
|
||||
* session it can send nothing, so a read-only guest cannot "disconnect" and
|
||||
* then type into the owner's shell.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTerminalSocket } from "../../src/backend/terminal-socket.js";
|
||||
|
||||
class FakeSocket extends EventEmitter {
|
||||
readyState = 1;
|
||||
sent: string[] = [];
|
||||
closedWith: number | null = null;
|
||||
send(message: string) {
|
||||
this.sent.push(message);
|
||||
}
|
||||
close(code = 1000) {
|
||||
this.closedWith = code;
|
||||
this.readyState = 3;
|
||||
this.emit("close");
|
||||
}
|
||||
ping() {}
|
||||
terminate() {
|
||||
this.close(1006);
|
||||
}
|
||||
message(type: string, data?: unknown) {
|
||||
this.emit("message", Buffer.from(JSON.stringify({ type, data })));
|
||||
}
|
||||
}
|
||||
|
||||
function fakeSessions(permissionLevel: "read-only" | "read-write") {
|
||||
const written: string[] = [];
|
||||
const participants = new Map<
|
||||
unknown,
|
||||
{ isOwner: boolean; permissionLevel: string }
|
||||
>();
|
||||
const session = {
|
||||
isConnected: true,
|
||||
sshStream: { write: (chunk: Buffer) => written.push(chunk.toString()) },
|
||||
};
|
||||
const manager = {
|
||||
getSession: (id: string) => (id === "s1" ? session : null),
|
||||
joinAsParticipant: (_id: string, ws: unknown) => {
|
||||
participants.set(ws, { isOwner: false, permissionLevel });
|
||||
return "s1";
|
||||
},
|
||||
getBuffer: () => "",
|
||||
getParticipantForWs: (_session: unknown, ws: unknown) =>
|
||||
participants.get(ws) ?? null,
|
||||
removeParticipant: (_id: string, ws: unknown) => {
|
||||
participants.delete(ws);
|
||||
},
|
||||
bufferInput: () => {},
|
||||
};
|
||||
return { manager, written };
|
||||
}
|
||||
|
||||
function socketFor(permissionLevel: "read-only" | "read-write") {
|
||||
const sessions = fakeSessions(permissionLevel);
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
const terminal = createTerminalSocket({
|
||||
ctx: {} as never,
|
||||
log: log as never,
|
||||
sessionManager: sessions.manager as never,
|
||||
getTmux: () => null,
|
||||
getSharing: () => null,
|
||||
getGuests: () => ({
|
||||
resolve: async () => ({
|
||||
ok: true,
|
||||
share: { id: "share-1", sessionId: "s1", permissionLevel },
|
||||
}),
|
||||
recordJoin: async () => {},
|
||||
}),
|
||||
} as never);
|
||||
return { terminal, written: sessions.written };
|
||||
}
|
||||
|
||||
async function joinAsGuest(terminal: ReturnType<typeof socketFor>["terminal"]) {
|
||||
const ws = new FakeSocket();
|
||||
await terminal.handleConnection({
|
||||
socket: ws,
|
||||
request: { url: "/terminal?shareToken=abc", headers: {} },
|
||||
userId: "",
|
||||
clientIp: "10.0.0.1",
|
||||
requestOrigin: "",
|
||||
isDataUnlocked: () => false,
|
||||
} as never);
|
||||
return ws;
|
||||
}
|
||||
|
||||
let cleanup: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
cleanup.forEach((fn) => fn());
|
||||
cleanup = [];
|
||||
});
|
||||
|
||||
describe("share-link guests", () => {
|
||||
it("cannot type into a read-only session", async () => {
|
||||
const { terminal, written } = socketFor("read-only");
|
||||
const ws = await joinAsGuest(terminal);
|
||||
cleanup.push(() => terminal.closeAll());
|
||||
ws.message("input", "rm -rf ~\n");
|
||||
expect(written).toEqual([]);
|
||||
});
|
||||
|
||||
it("cannot type after leaving the session", async () => {
|
||||
const { terminal, written } = socketFor("read-only");
|
||||
const ws = await joinAsGuest(terminal);
|
||||
cleanup.push(() => terminal.closeAll());
|
||||
ws.message("disconnect");
|
||||
ws.message("input", "rm -rf ~\n");
|
||||
expect(written).toEqual([]);
|
||||
expect(ws.closedWith).toBe(1000);
|
||||
});
|
||||
|
||||
it("a read-write guest types until it leaves", async () => {
|
||||
const { terminal, written } = socketFor("read-write");
|
||||
const ws = await joinAsGuest(terminal);
|
||||
cleanup.push(() => terminal.closeAll());
|
||||
ws.message("input", "ls\n");
|
||||
ws.message("disconnect");
|
||||
ws.message("input", "whoami\n");
|
||||
expect(written).toEqual(["ls\n"]);
|
||||
});
|
||||
});
|
||||
@@ -418,7 +418,12 @@ export function createSsoLogin(ctx: PluginContext, store: ProviderStore) {
|
||||
kind: "external",
|
||||
provider: providerKey(provider),
|
||||
subject: String(identifier),
|
||||
email: (userInfo.email as string | undefined) ?? null,
|
||||
// An address the provider says it has not verified does not count
|
||||
// toward the allowed list.
|
||||
email:
|
||||
userInfo.email_verified === false || userInfo.email_verified === "false"
|
||||
? null
|
||||
: ((userInfo.email as string | undefined) ?? null),
|
||||
name,
|
||||
groups,
|
||||
isAdmin,
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"auth:provide",
|
||||
"ssh:connect",
|
||||
"credentials:use",
|
||||
"credentials:read",
|
||||
"ui:surface"
|
||||
],
|
||||
"optionalDependencies": {
|
||||
|
||||
Reference in new issue
Block a user