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:
@@ -2034,7 +2034,7 @@ httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
||||
});
|
||||
|
||||
export const serverReady = new Promise<void>((resolve) => {
|
||||
httpServer.listen(HTTP_PORT, async () => {
|
||||
httpServer.listen(HTTP_PORT, "127.0.0.1", async () => {
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
@@ -2083,7 +2083,7 @@ if (
|
||||
});
|
||||
});
|
||||
|
||||
httpsServer.listen(sslConfig.port, () => {
|
||||
httpsServer.listen(sslConfig.port, "127.0.0.1", () => {
|
||||
databaseLogger.success(
|
||||
`Backend is now also listening for HTTPS directly`,
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
HOST_ADDRESS_MISMATCH_MESSAGE,
|
||||
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
||||
} from "../terminal/host-identity.js";
|
||||
import { extractWebSocketToken } from "../../utils/ws-auth.js";
|
||||
|
||||
const sshLogger = systemLogger;
|
||||
|
||||
@@ -35,7 +36,7 @@ interface SSHSession {
|
||||
const activeSessions = new Map<string, SSHSession>();
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
host: "0.0.0.0",
|
||||
host: "127.0.0.1",
|
||||
port: 30009,
|
||||
});
|
||||
|
||||
@@ -285,26 +286,7 @@ async function createJumpHostChain(
|
||||
}
|
||||
|
||||
wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let token: string | undefined;
|
||||
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
token = authHeader.slice("Bearer ".length);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const urlObj = new URL(req.url || "", "http://localhost");
|
||||
const qp = urlObj.searchParams.get("token");
|
||||
if (qp) token = qp;
|
||||
}
|
||||
const token = extractWebSocketToken(req);
|
||||
|
||||
if (!token) {
|
||||
ws.close(1008, "Authentication required");
|
||||
|
||||
@@ -47,7 +47,7 @@ registerDockerContainerRoutes(app, {
|
||||
|
||||
const PORT = 30007;
|
||||
|
||||
app.listen(PORT, async () => {
|
||||
app.listen(PORT, "127.0.0.1", async () => {
|
||||
try {
|
||||
await authManager.initialize();
|
||||
} catch (err) {
|
||||
|
||||
@@ -3128,7 +3128,7 @@ process.on("SIGTERM", () => {
|
||||
const PORT = 30004;
|
||||
|
||||
try {
|
||||
const server = app.listen(PORT, async () => {
|
||||
const server = app.listen(PORT, "127.0.0.1", async () => {
|
||||
try {
|
||||
await authManager.initialize();
|
||||
} catch (err) {
|
||||
|
||||
@@ -140,6 +140,7 @@ async function persistGuacamoleRecording(
|
||||
}
|
||||
|
||||
const websocketOptions = {
|
||||
host: "127.0.0.1",
|
||||
port: GUAC_WS_PORT,
|
||||
};
|
||||
|
||||
|
||||
@@ -3071,7 +3071,7 @@ process.on("SIGTERM", () => {
|
||||
});
|
||||
|
||||
const PORT = 30005;
|
||||
app.listen(PORT, async () => {
|
||||
app.listen(PORT, "127.0.0.1", async () => {
|
||||
try {
|
||||
await authManager.initialize();
|
||||
} catch (err) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { parseWsMessage } from "../utils/ws-message.js";
|
||||
import { extractWebSocketToken } from "../utils/ws-auth.js";
|
||||
|
||||
interface SerialConnectData {
|
||||
path: string;
|
||||
@@ -16,7 +17,7 @@ interface SerialConnectData {
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
const wss = new WebSocketServer({ port: 30011 });
|
||||
const wss = new WebSocketServer({ host: "127.0.0.1", port: 30011 });
|
||||
|
||||
wss.on("error", (error) => {
|
||||
sshLogger.error("Serial WebSocket server error", error, {
|
||||
@@ -28,26 +29,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let userId: string | undefined;
|
||||
|
||||
try {
|
||||
let token: string | undefined;
|
||||
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
token = authHeader.slice("Bearer ".length);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const urlObj = new URL(req.url || "", "http://localhost");
|
||||
const qp = urlObj.searchParams.get("token");
|
||||
if (qp) token = qp;
|
||||
}
|
||||
const token = extractWebSocketToken(req);
|
||||
|
||||
if (!token) {
|
||||
ws.close(1008, "Authentication required");
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
||||
resolveServerJumpHosts,
|
||||
} from "./host-identity.js";
|
||||
import { extractWebSocketToken } from "../../utils/ws-auth.js";
|
||||
|
||||
interface ConnectToHostData {
|
||||
cols: number;
|
||||
@@ -128,6 +129,7 @@ const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000;
|
||||
const userConnections = new Map<string, Set<WebSocket>>();
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
host: "127.0.0.1",
|
||||
port: 30002,
|
||||
});
|
||||
|
||||
@@ -299,25 +301,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
|
||||
try {
|
||||
let token: string | undefined;
|
||||
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
token = authHeader.slice("Bearer ".length);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const qp = urlObj.searchParams.get("token");
|
||||
if (qp) token = qp;
|
||||
}
|
||||
const token = extractWebSocketToken(req);
|
||||
|
||||
if (!token) {
|
||||
ws.close(1008, "Authentication required");
|
||||
|
||||
@@ -923,4 +923,4 @@ app.put("/tmux_monitor/:hostId/tags", async (req, res) => {
|
||||
});
|
||||
|
||||
const PORT = 30010;
|
||||
app.listen(PORT, () => {});
|
||||
app.listen(PORT, "127.0.0.1", () => {});
|
||||
|
||||
@@ -2,24 +2,14 @@ import type { IncomingMessage } from "http";
|
||||
import type { Duplex } from "stream";
|
||||
import type { ClientChannel } from "ssh2";
|
||||
import type { WebSocket } from "ws";
|
||||
import { extractWebSocketToken } from "../../utils/ws-auth.js";
|
||||
|
||||
const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
|
||||
const C2S_WS_LOW_WATERMARK = 256 * 1024;
|
||||
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
|
||||
|
||||
export function extractRequestToken(req: IncomingMessage): string | undefined {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) return decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
return authHeader.slice("Bearer ".length);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return extractWebSocketToken(req);
|
||||
}
|
||||
|
||||
export function sendC2SError(ws: WebSocket, message: string): void {
|
||||
|
||||
@@ -100,7 +100,7 @@ c2sRelayWss.on("connection", (ws, req) => {
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
server.listen(PORT, "127.0.0.1", () => {
|
||||
setTimeout(() => {
|
||||
initializeAutoStartTunnels();
|
||||
}, 2000);
|
||||
|
||||
@@ -318,7 +318,7 @@ app.delete("/activity/reset", async (req, res) => {
|
||||
app.use("/service-links", dashboardServiceLinksRouter);
|
||||
|
||||
const PORT = 30006;
|
||||
app.listen(PORT, async () => {
|
||||
app.listen(PORT, "127.0.0.1", async () => {
|
||||
try {
|
||||
await authManager.initialize();
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { IncomingMessage } from "http";
|
||||
import { extractWebSocketToken } from "../../utils/ws-auth.js";
|
||||
|
||||
function request(headers: Record<string, string>): IncomingMessage {
|
||||
return { headers } as IncomingMessage;
|
||||
}
|
||||
|
||||
describe("extractWebSocketToken", () => {
|
||||
it("reads JWTs from the WebSocket subprotocol without using the URL", () => {
|
||||
expect(
|
||||
extractWebSocketToken(
|
||||
request({ "sec-websocket-protocol": "termix.jwt.header.payload.sig" }),
|
||||
),
|
||||
).toBe("header.payload.sig");
|
||||
});
|
||||
|
||||
it("prefers the HttpOnly cookie over renderer-provided protocols", () => {
|
||||
expect(
|
||||
extractWebSocketToken(
|
||||
request({
|
||||
cookie: "jwt=cookie-token",
|
||||
"sec-websocket-protocol": "termix.jwt.protocol-token",
|
||||
}),
|
||||
),
|
||||
).toBe("cookie-token");
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,35 @@ class SystemCrypto {
|
||||
|
||||
private constructor() {}
|
||||
|
||||
private async readExternalSecret(
|
||||
name: string,
|
||||
minimumLength: number,
|
||||
): Promise<string | null> {
|
||||
const direct = process.env[name]?.trim();
|
||||
if (direct && direct.length >= minimumLength) return direct;
|
||||
|
||||
const secretFile = process.env[`${name}_FILE`]?.trim();
|
||||
if (!secretFile) return null;
|
||||
const value = (await fs.readFile(secretFile, "utf8")).trim();
|
||||
if (value.length < minimumLength) {
|
||||
throw new Error(`${name}_FILE contains a secret that is too short`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private requireExternalSecret(name: string): never {
|
||||
throw new Error(
|
||||
`${name} must be supplied through ${name} or ${name}_FILE when TERMIX_REQUIRE_EXTERNAL_SECRETS=true`,
|
||||
);
|
||||
}
|
||||
|
||||
private parseExternalHexKey(name: string, value: string): Buffer {
|
||||
if (!/^[0-9a-f]{64}$/i.test(value)) {
|
||||
throw new Error(`${name} must contain exactly 64 hexadecimal characters`);
|
||||
}
|
||||
return Buffer.from(value, "hex");
|
||||
}
|
||||
|
||||
static getInstance(): SystemCrypto {
|
||||
if (!this.instance) {
|
||||
this.instance = new SystemCrypto();
|
||||
@@ -21,8 +50,8 @@ class SystemCrypto {
|
||||
|
||||
async initializeJWTSecret(): Promise<void> {
|
||||
try {
|
||||
const envSecret = process.env.JWT_SECRET;
|
||||
if (envSecret && envSecret.length >= 64) {
|
||||
const envSecret = await this.readExternalSecret("JWT_SECRET", 64);
|
||||
if (envSecret) {
|
||||
this.jwtSecret = envSecret;
|
||||
return;
|
||||
}
|
||||
@@ -39,7 +68,6 @@ class SystemCrypto {
|
||||
databaseLogger.success("JWT secret loaded from .env file", {
|
||||
operation: "jwt_init_from_file_success",
|
||||
secretLength: jwtMatch[1].length,
|
||||
secretPrefix: jwtMatch[1].substring(0, 8) + "...",
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
@@ -56,6 +84,9 @@ class SystemCrypto {
|
||||
// expected - env file may not exist
|
||||
}
|
||||
|
||||
if (process.env.TERMIX_REQUIRE_EXTERNAL_SECRETS === "true") {
|
||||
this.requireExternalSecret("JWT_SECRET");
|
||||
}
|
||||
await this.generateAndGuideUser();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize JWT secret", error, {
|
||||
@@ -77,9 +108,9 @@ class SystemCrypto {
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
|
||||
const envKey = process.env.DATABASE_KEY;
|
||||
if (envKey && envKey.length >= 64) {
|
||||
this.databaseKey = Buffer.from(envKey, "hex");
|
||||
const envKey = await this.readExternalSecret("DATABASE_KEY", 64);
|
||||
if (envKey) {
|
||||
this.databaseKey = this.parseExternalHexKey("DATABASE_KEY", envKey);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -97,6 +128,9 @@ class SystemCrypto {
|
||||
// expected - env file may not exist
|
||||
}
|
||||
|
||||
if (process.env.TERMIX_REQUIRE_EXTERNAL_SECRETS === "true") {
|
||||
this.requireExternalSecret("DATABASE_KEY");
|
||||
}
|
||||
await this.generateAndGuideDatabaseKey();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize database key", error, {
|
||||
@@ -119,9 +153,9 @@ class SystemCrypto {
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
|
||||
const envKey = process.env.ENCRYPTION_KEY;
|
||||
if (envKey && envKey.length >= 64) {
|
||||
this.encryptionKey = Buffer.from(envKey, "hex");
|
||||
const envKey = await this.readExternalSecret("ENCRYPTION_KEY", 64);
|
||||
if (envKey) {
|
||||
this.encryptionKey = this.parseExternalHexKey("ENCRYPTION_KEY", envKey);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,6 +171,9 @@ class SystemCrypto {
|
||||
// expected - env file may not exist
|
||||
}
|
||||
|
||||
if (process.env.TERMIX_REQUIRE_EXTERNAL_SECRETS === "true") {
|
||||
this.requireExternalSecret("ENCRYPTION_KEY");
|
||||
}
|
||||
await this.generateAndGuideEncryptionKey();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize encryption key", error, {
|
||||
@@ -156,8 +193,8 @@ class SystemCrypto {
|
||||
|
||||
async initializeInternalAuthToken(): Promise<void> {
|
||||
try {
|
||||
const envToken = process.env.INTERNAL_AUTH_TOKEN;
|
||||
if (envToken && envToken.length >= 32) {
|
||||
const envToken = await this.readExternalSecret("INTERNAL_AUTH_TOKEN", 32);
|
||||
if (envToken) {
|
||||
this.internalAuthToken = envToken;
|
||||
return;
|
||||
}
|
||||
@@ -177,6 +214,9 @@ class SystemCrypto {
|
||||
// expected - env file may not exist
|
||||
}
|
||||
|
||||
if (process.env.TERMIX_REQUIRE_EXTERNAL_SECRETS === "true") {
|
||||
this.requireExternalSecret("INTERNAL_AUTH_TOKEN");
|
||||
}
|
||||
await this.generateAndGuideInternalAuthToken();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize internal auth token", error, {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { IncomingMessage } from "http";
|
||||
|
||||
const JWT_PROTOCOL_PREFIX = "termix.jwt.";
|
||||
|
||||
export function extractWebSocketToken(
|
||||
req: IncomingMessage,
|
||||
): string | undefined {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) return decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
return authHeader.slice("Bearer ".length);
|
||||
}
|
||||
|
||||
const protocols = String(req.headers["sec-websocket-protocol"] || "")
|
||||
.split(",")
|
||||
.map((protocol) => protocol.trim());
|
||||
const jwtProtocol = protocols.find((protocol) =>
|
||||
protocol.startsWith(JWT_PROTOCOL_PREFIX),
|
||||
);
|
||||
return jwtProtocol?.slice(JWT_PROTOCOL_PREFIX.length);
|
||||
}
|
||||
Reference in New Issue
Block a user