SECURITY: Eliminate complex fallback storage, enforce environment variables
Core changes: - Remove file/database fallback storage complexity - Enforce JWT_SECRET and DATABASE_KEY as environment variables only - Auto-generate keys on first startup with clear user guidance - Eliminate circular dependencies and storage layer abstractions Security improvements: - Single source of truth for secrets (environment variables) - No persistent storage of secrets in files or database - Clear deployment guidance for production environments - Simplified attack surface by removing storage complexity WebSocket authentication: - Implement JWT authentication for WebSocket handshake - Add connection limits and user tracking - Update frontend to pass JWT tokens in WebSocket URLs - Configure Nginx for authenticated WebSocket proxy Additional fixes: - Replace CORS wildcard with specific origins - Remove password logging security vulnerability - Streamline encryption architecture following Linus principles
This commit is contained in:
@@ -1,34 +1,195 @@
|
||||
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||||
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
|
||||
import { parse as parseUrl } from "url";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { UserCrypto } from "../utils/user-crypto.js";
|
||||
|
||||
const wss = new WebSocketServer({ port: 8082 });
|
||||
// Get auth instances
|
||||
const authManager = AuthManager.getInstance();
|
||||
const userCrypto = UserCrypto.getInstance();
|
||||
|
||||
sshLogger.success("SSH Terminal WebSocket server started", {
|
||||
operation: "server_start",
|
||||
// Track user connections for rate limiting
|
||||
const userConnections = new Map<string, Set<WebSocket>>();
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
port: 8082,
|
||||
// WebSocket authentication during handshake
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
const url = parseUrl(info.req.url!, true);
|
||||
const token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
sshLogger.warn("WebSocket connection rejected: missing token", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "missing_token",
|
||||
origin: info.origin,
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify JWT token
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
if (!payload) {
|
||||
sshLogger.warn("WebSocket connection rejected: invalid token", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "invalid_token",
|
||||
origin: info.origin,
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for TOTP pending (should not allow terminal access during TOTP)
|
||||
if (payload.pendingTOTP) {
|
||||
sshLogger.warn("WebSocket connection rejected: TOTP verification pending", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "totp_pending",
|
||||
userId: payload.userId,
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check connection limits per user (max 3 concurrent connections)
|
||||
const existingConnections = userConnections.get(payload.userId);
|
||||
if (existingConnections && existingConnections.size >= 3) {
|
||||
sshLogger.warn("WebSocket connection rejected: too many connections", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "connection_limit",
|
||||
userId: payload.userId,
|
||||
currentConnections: existingConnections.size,
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Attach user info to request object
|
||||
(info.req as any).userId = payload.userId;
|
||||
(info.req as any).userPayload = payload;
|
||||
|
||||
sshLogger.info("WebSocket connection authenticated", {
|
||||
operation: "websocket_auth_success",
|
||||
userId: payload.userId,
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
sshLogger.error("WebSocket authentication error", error, {
|
||||
operation: "websocket_auth_error",
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
wss.on("connection", (ws: WebSocket) => {
|
||||
sshLogger.success("SSH Terminal WebSocket server started with authentication", {
|
||||
operation: "server_start",
|
||||
port: 8082,
|
||||
features: ["JWT_auth", "connection_limits", "data_access_control"]
|
||||
});
|
||||
|
||||
wss.on("connection", (ws: WebSocket, req) => {
|
||||
// Extract authenticated user info from request
|
||||
const userId = (req as any).userId;
|
||||
const userPayload = (req as any).userPayload;
|
||||
|
||||
if (!userId) {
|
||||
sshLogger.error("WebSocket connection without authentication - should not happen", {
|
||||
operation: "websocket_security_violation",
|
||||
ip: req.socket.remoteAddress
|
||||
});
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check data access permissions
|
||||
const dataKey = userCrypto.getUserDataKey(userId);
|
||||
if (!dataKey) {
|
||||
sshLogger.warn("WebSocket connection rejected: data locked", {
|
||||
operation: "websocket_data_locked",
|
||||
userId,
|
||||
ip: req.socket.remoteAddress
|
||||
});
|
||||
ws.send(JSON.stringify({
|
||||
type: "error",
|
||||
message: "Data locked - re-authenticate with password",
|
||||
code: "DATA_LOCKED"
|
||||
}));
|
||||
ws.close(1008, "Data access required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Track user connections for limits
|
||||
if (!userConnections.has(userId)) {
|
||||
userConnections.set(userId, new Set());
|
||||
}
|
||||
const userWs = userConnections.get(userId)!;
|
||||
userWs.add(ws);
|
||||
|
||||
sshLogger.info("WebSocket connection established", {
|
||||
operation: "websocket_connection_established",
|
||||
userId,
|
||||
userConnections: userWs.size,
|
||||
ip: req.socket.remoteAddress
|
||||
});
|
||||
|
||||
let sshConn: Client | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
ws.on("close", () => {
|
||||
// Clean up user connection tracking
|
||||
const userWs = userConnections.get(userId);
|
||||
if (userWs) {
|
||||
userWs.delete(ws);
|
||||
if (userWs.size === 0) {
|
||||
userConnections.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
sshLogger.info("WebSocket connection closed", {
|
||||
operation: "websocket_connection_closed",
|
||||
userId,
|
||||
remainingConnections: userWs?.size || 0
|
||||
});
|
||||
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
ws.on("message", (msg: RawData) => {
|
||||
// Verify user still has data access before processing any messages
|
||||
const currentDataKey = userCrypto.getUserDataKey(userId);
|
||||
if (!currentDataKey) {
|
||||
sshLogger.warn("WebSocket message rejected: data access expired", {
|
||||
operation: "websocket_message_rejected",
|
||||
userId,
|
||||
reason: "data_access_expired"
|
||||
});
|
||||
ws.send(JSON.stringify({
|
||||
type: "error",
|
||||
message: "Data access expired - please re-authenticate",
|
||||
code: "DATA_EXPIRED"
|
||||
}));
|
||||
ws.close(1008, "Data access expired");
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(msg.toString());
|
||||
} catch (e) {
|
||||
sshLogger.error("Invalid JSON received", e, {
|
||||
operation: "websocket_message",
|
||||
operation: "websocket_message_invalid_json",
|
||||
userId,
|
||||
messageLength: msg.toString().length,
|
||||
});
|
||||
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
||||
@@ -39,9 +200,14 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
|
||||
switch (type) {
|
||||
case "connectToHost":
|
||||
// Ensure userId is attached to hostConfig for secure credential resolution
|
||||
if (data.hostConfig) {
|
||||
data.hostConfig.userId = userId;
|
||||
}
|
||||
handleConnectToHost(data).catch((error) => {
|
||||
sshLogger.error("Failed to connect to host", error, {
|
||||
operation: "ssh_connect",
|
||||
userId,
|
||||
hostId: data.hostConfig?.id,
|
||||
ip: data.hostConfig?.ip,
|
||||
});
|
||||
@@ -82,7 +248,8 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
|
||||
default:
|
||||
sshLogger.warn("Unknown message type received", {
|
||||
operation: "websocket_message",
|
||||
operation: "websocket_message_unknown_type",
|
||||
userId,
|
||||
messageType: type,
|
||||
});
|
||||
}
|
||||
@@ -187,15 +354,15 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
hasCredentialId: !!credentialId,
|
||||
});
|
||||
|
||||
if (password) {
|
||||
sshLogger.debug(`Password preview: "${password.substring(0, 15)}..."`, {
|
||||
operation: "terminal_ssh_password",
|
||||
});
|
||||
} else {
|
||||
sshLogger.debug(`No password provided`, {
|
||||
operation: "terminal_ssh_password",
|
||||
});
|
||||
}
|
||||
// SECURITY: Never log password information - removed password preview logging
|
||||
sshLogger.debug(`SSH authentication setup`, {
|
||||
operation: "terminal_ssh_auth_setup",
|
||||
userId,
|
||||
hostId: id,
|
||||
authType,
|
||||
hasPassword: !!password,
|
||||
hasCredentialId: !!credentialId,
|
||||
});
|
||||
|
||||
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||
if (credentialId && id && hostConfig.userId) {
|
||||
|
||||
Reference in New Issue
Block a user