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:
ZacharyZcR
2025-09-22 08:57:37 +08:00
parent ed11b309f4
commit dfc92428e0
6 changed files with 316 additions and 315 deletions

View File

@@ -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) {