From aea00225d2bdf3eb2e8d34402a0260afced07d31 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 22 Sep 2025 21:52:25 +0800 Subject: [PATCH] SECURITY: Fix authentication and file manager display issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add JWT authentication middleware to file manager and metrics APIs - Fix WebSocket authentication timing race conditions - Resolve file manager grid view display issue by eliminating request ID complexity - Fix FileViewer translation function undefined error - Simplify SSH authentication flow and remove duplicate connection attempts - Ensure consistent user authentication across all services 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/backend/ssh/file-manager.ts | 31 +++++++- src/backend/ssh/server-stats.ts | 69 ++++++++++++----- src/backend/ssh/terminal.ts | 77 ++++++++++++++++--- .../Apps/File Manager/FileManagerGrid.tsx | 18 ----- .../Apps/File Manager/FileManagerModern.tsx | 41 +++------- .../File Manager/components/FileViewer.tsx | 2 + 6 files changed, 159 insertions(+), 79 deletions(-) diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index bc6f054c..e1741b4f 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -6,6 +6,7 @@ import { sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { fileLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; +import { AuthManager } from "../utils/auth-manager.js"; // Executable file detection utility function function isExecutableFile(permissions: string, fileName: string): boolean { @@ -62,6 +63,10 @@ app.use(express.json({ limit: "1gb" })); app.use(express.urlencoded({ limit: "1gb", extended: true })); app.use(express.raw({ limit: "5gb", type: "application/octet-stream" })); +// Initialize AuthManager and add authentication middleware +const authManager = AuthManager.getInstance(); +app.use(authManager.createAuthMiddleware()); + interface SSHSession { client: SSHClient; isConnected: boolean; @@ -108,9 +113,19 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { keyPassword, authType, credentialId, - userId, } = req.body; + // Use authenticated user ID from middleware + const userId = (req as any).userId; + + if (!userId) { + fileLogger.error("SSH connection rejected: no authenticated user", { + operation: "file_connect_auth", + sessionId, + }); + return res.status(401).json({ error: "Authentication required" }); + } + if (!sessionId || !ip || !username || !port) { fileLogger.warn("Missing SSH connection parameters for file manager", { operation: "file_connect", @@ -2052,9 +2067,21 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => { }); const PORT = 8084; -app.listen(PORT, () => { +app.listen(PORT, async () => { fileLogger.success("File Manager API server started", { operation: "server_start", port: PORT, }); + + // Initialize AuthManager for JWT verification + try { + await authManager.initialize(); + fileLogger.info("AuthManager initialized for file manager", { + operation: "auth_init", + }); + } catch (err) { + fileLogger.error("Failed to initialize AuthManager", err, { + operation: "auth_init_error", + }); + } }); diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 6ae2d1f4..971ead78 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -7,6 +7,7 @@ import { sshData, sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { statsLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; +import { AuthManager } from "../utils/auth-manager.js"; interface PooledConnection { client: Client; @@ -228,6 +229,7 @@ class MetricsCache { const connectionPool = new SSHConnectionPool(); const requestQueue = new RequestQueue(); const metricsCache = new MetricsCache(); +const authManager = AuthManager.getInstance(); type HostStatus = "online" | "offline"; @@ -303,19 +305,23 @@ app.use((req, res, next) => { }); app.use(express.json({ limit: "1mb" })); +// Add authentication middleware - Linus principle: eliminate special cases +app.use(authManager.createAuthMiddleware()); + const hostStatuses: Map = new Map(); -async function fetchAllHosts(): Promise { +async function fetchAllHosts(userId: string): Promise { try { - const hosts = await SimpleDBOps.selectEncrypted( - getDb().select().from(sshData), + const hosts = await SimpleDBOps.select( + getDb().select().from(sshData).where(eq(sshData.userId, userId)), "ssh_data", + userId, ); const hostsWithCredentials: SSHHostWithCredentials[] = []; for (const host of hosts) { try { - const hostWithCreds = await resolveHostCredentials(host); + const hostWithCreds = await resolveHostCredentials(host, userId); if (hostWithCreds) { hostsWithCredentials.push(hostWithCreds); } @@ -335,11 +341,13 @@ async function fetchAllHosts(): Promise { async function fetchHostById( id: number, + userId: string, ): Promise { try { - const hosts = await SimpleDBOps.selectEncrypted( - getDb().select().from(sshData).where(eq(sshData.id, id)), + const hosts = await SimpleDBOps.select( + getDb().select().from(sshData).where(and(eq(sshData.id, id), eq(sshData.userId, userId))), "ssh_data", + userId, ); if (hosts.length === 0) { @@ -347,7 +355,7 @@ async function fetchHostById( } const host = hosts[0]; - return await resolveHostCredentials(host); + return await resolveHostCredentials(host, userId); } catch (err) { statsLogger.error(`Failed to fetch host ${id}`, err); return undefined; @@ -356,6 +364,7 @@ async function fetchHostById( async function resolveHostCredentials( host: any, + userId: string, ): Promise { try { const baseHost: any = { @@ -387,17 +396,18 @@ async function resolveHostCredentials( if (host.credentialId) { try { - const credentials = await SimpleDBOps.selectEncrypted( + const credentials = await SimpleDBOps.select( getDb() .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, host.credentialId), - eq(sshCredentials.userId, host.userId), + eq(sshCredentials.userId, userId), ), ), "ssh_credentials", + userId, ); if (credentials.length > 0) { @@ -809,11 +819,19 @@ function tcpPing( }); } -async function pollStatusesOnce(): Promise { - const hosts = await fetchAllHosts(); +async function pollStatusesOnce(userId?: string): Promise { + if (!userId) { + statsLogger.warn("Skipping status poll - no authenticated user", { + operation: "status_poll", + }); + return; + } + + const hosts = await fetchAllHosts(userId); if (hosts.length === 0) { statsLogger.warn("No hosts retrieved for status polling", { operation: "status_poll", + userId, }); return; } @@ -845,8 +863,10 @@ async function pollStatusesOnce(): Promise { } app.get("/status", async (req, res) => { + const userId = (req as any).userId; + if (hostStatuses.size === 0) { - await pollStatusesOnce(); + await pollStatusesOnce(userId); } const result: Record = {}; for (const [id, entry] of hostStatuses.entries()) { @@ -857,9 +877,10 @@ app.get("/status", async (req, res) => { app.get("/status/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); + const userId = (req as any).userId; try { - const host = await fetchHostById(id); + const host = await fetchHostById(id, userId); if (!host) { return res.status(404).json({ error: "Host not found" }); } @@ -880,15 +901,17 @@ app.get("/status/:id", validateHostId, async (req, res) => { }); app.post("/refresh", async (req, res) => { - await pollStatusesOnce(); + const userId = (req as any).userId; + await pollStatusesOnce(userId); res.json({ message: "Refreshed" }); }); app.get("/metrics/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); + const userId = (req as any).userId; try { - const host = await fetchHostById(id); + const host = await fetchHostById(id, userId); if (!host) { return res.status(404).json({ error: "Host not found" }); } @@ -947,11 +970,21 @@ app.listen(PORT, async () => { operation: "server_start", port: PORT, }); + + // Initialize AuthManager for JWT verification try { - await pollStatusesOnce(); + await authManager.initialize(); + statsLogger.info("AuthManager initialized for metrics collection", { + operation: "auth_init", + }); } catch (err) { - statsLogger.error("Initial poll failed", err, { - operation: "initial_poll", + statsLogger.error("Failed to initialize AuthManager", err, { + operation: "auth_init_error", }); } + + // Skip initial poll - requires user authentication + statsLogger.info("Server ready - status polling will begin with first authenticated request", { + operation: "server_ready", + }); }); diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 417902cb..96897c9d 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -24,24 +24,50 @@ const wss = new WebSocketServer({ const url = parseUrl(info.req.url!, true); const token = url.query.token as string; + // DEBUG: Log detailed JWT verification process + sshLogger.debug("WebSocket JWT verification starting", { + operation: "websocket_jwt_debug", + fullUrl: info.req.url, + hasToken: !!token, + tokenLength: token?.length || 0, + tokenStart: token ? token.substring(0, 20) + "..." : "missing", + ip: info.req.socket.remoteAddress + }); + if (!token) { sshLogger.warn("WebSocket connection rejected: missing token", { operation: "websocket_auth_reject", reason: "missing_token", origin: info.origin, - ip: info.req.socket.remoteAddress + ip: info.req.socket.remoteAddress, + queryKeys: Object.keys(url.query || {}) }); return false; } // Verify JWT token + sshLogger.debug("Calling authManager.verifyJWTToken", { + operation: "websocket_jwt_verify", + tokenLength: token.length + }); + const payload = await authManager.verifyJWTToken(token); + + sshLogger.debug("JWT verification result", { + operation: "websocket_jwt_result", + hasPayload: !!payload, + payloadKeys: payload ? Object.keys(payload) : [], + userId: payload?.userId || "none" + }); + if (!payload) { sshLogger.warn("WebSocket connection rejected: invalid token", { operation: "websocket_auth_reject", reason: "invalid_token", origin: info.origin, - ip: info.req.socket.remoteAddress + ip: info.req.socket.remoteAddress, + tokenLength: token.length, + tokenStart: token.substring(0, 20) + "..." }); return false; } @@ -70,9 +96,8 @@ const wss = new WebSocketServer({ return false; } - // Attach user info to request object - (info.req as any).userId = payload.userId; - (info.req as any).userPayload = payload; + // Note: We don't need to attach user info to request anymore + // Connection handler will re-verify JWT directly from URL sshLogger.info("WebSocket connection authenticated", { operation: "websocket_auth_success", @@ -97,14 +122,42 @@ sshLogger.success("SSH Terminal WebSocket server started with authentication", { 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; +wss.on("connection", async (ws: WebSocket, req) => { + // Linus principle: eliminate complexity - always parse JWT from URL directly + let userId: string | undefined; + let userPayload: any; - if (!userId) { - sshLogger.error("WebSocket connection without authentication - should not happen", { - operation: "websocket_security_violation", + try { + const url = parseUrl(req.url!, true); + const token = url.query.token as string; + + if (!token) { + sshLogger.warn("WebSocket connection rejected: missing token in connection", { + operation: "websocket_connection_reject", + reason: "missing_token", + ip: req.socket.remoteAddress + }); + ws.close(1008, "Authentication required"); + return; + } + + const payload = await authManager.verifyJWTToken(token); + if (!payload) { + sshLogger.warn("WebSocket connection rejected: invalid token in connection", { + operation: "websocket_connection_reject", + reason: "invalid_token", + ip: req.socket.remoteAddress + }); + ws.close(1008, "Authentication required"); + return; + } + + userId = payload.userId; + userPayload = payload; + + } catch (error) { + sshLogger.error("WebSocket JWT verification failed during connection", error, { + operation: "websocket_connection_auth_error", ip: req.socket.remoteAddress }); ws.close(1008, "Authentication required"); diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx index b33fa7be..5c084360 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx @@ -1138,24 +1138,6 @@ export function FileManagerGrid({ (f) => f.path === file.path, ); - // Detailed debug path comparison - if (selectedFiles.length > 0) { - console.log(`\n=== File: ${file.name} ===`); - console.log(`File path: "${file.path}"`); - console.log( - `Selected files:`, - selectedFiles.map((f) => `"${f.path}"`), - ); - console.log( - `Path comparison results:`, - selectedFiles.map( - (f) => - `"${f.path}" === "${file.path}" -> ${f.path === file.path}`, - ), - ); - console.log(`Final isSelected: ${isSelected}`); - } - return (
([]); const [isLoading, setIsLoading] = useState(false); const [sshSessionId, setSshSessionId] = useState(null); - const [currentRequestId, setCurrentRequestId] = useState(0); const [isReconnecting, setIsReconnecting] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [lastRefreshTime, setLastRefreshTime] = useState(0); @@ -231,56 +230,39 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { } } - async function loadDirectory(path: string) { + const loadDirectory = useCallback(async (path: string) => { if (!sshSessionId) { console.error("Cannot load directory: no SSH session ID"); return; } - // Generate unique request ID to prevent race conditions - const requestId = Date.now(); - setCurrentRequestId(requestId); setIsLoading(true); try { - console.log(`[${requestId}] Loading directory:`, path); + console.log("Loading directory:", path); const response = await listSSHFiles(sshSessionId, path); - // Only process response if this is still the latest request - if (requestId !== currentRequestId) { - console.log(`[${requestId}] Request outdated, ignoring response`); - return; - } - - console.log(`[${requestId}] Directory response received:`, response); + console.log("Directory response received:", response); const files = Array.isArray(response) ? response : response?.files || []; + + console.log("Directory loaded successfully:", files.length, "items"); + setFiles(files); clearSelection(); - - console.log(`[${requestId}] Directory loaded successfully:`, files.length, "items"); } catch (error: any) { - // Only handle error if this is still the latest request - if (requestId !== currentRequestId) { - console.log(`[${requestId}] Request outdated, ignoring error`); - return; - } - - console.error(`[${requestId}] Failed to load directory:`, error); + console.error("Failed to load directory:", error); toast.error( t("fileManager.failedToLoadDirectory") + ": " + (error.message || error) ); } finally { - // Only clear loading if this is still the latest request - if (requestId === currentRequestId) { - setIsLoading(false); - } + setIsLoading(false); } - } + }, [sshSessionId, clearSelection, t]); // Debounced refresh function - prevent excessive clicking - function handleRefreshDirectory() { + const handleRefreshDirectory = useCallback(() => { const now = Date.now(); const DEBOUNCE_MS = 500; // 500ms debounce @@ -291,7 +273,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { setLastRefreshTime(now); loadDirectory(currentPath); - } + }, [currentPath, lastRefreshTime, loadDirectory]); function handleFilesDropped(fileList: FileList) { if (!sshSessionId) { @@ -1465,6 +1447,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { file.name.toLowerCase().includes(searchQuery.toLowerCase()), ); + if (!currentHost) { return (
diff --git a/src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx b/src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx index 528536bb..eeaed1af 100644 --- a/src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx +++ b/src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from "react"; import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; import { FileText, Image as ImageIcon, @@ -276,6 +277,7 @@ export function FileViewer({ onSave, onDownload, }: FileViewerProps) { + const { t } = useTranslation(); const [editedContent, setEditedContent] = useState(content); const [originalContent, setOriginalContent] = useState( savedContent || content,