SECURITY: Fix authentication and file manager display issues

- 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 <noreply@anthropic.com>
This commit is contained in:
ZacharyZcR
2025-09-22 21:52:25 +08:00
parent b8a94017c9
commit aea00225d2
6 changed files with 159 additions and 79 deletions

View File

@@ -6,6 +6,7 @@ import { sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { fileLogger } from "../utils/logger.js"; import { fileLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
// Executable file detection utility function // Executable file detection utility function
function isExecutableFile(permissions: string, fileName: string): boolean { 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.urlencoded({ limit: "1gb", extended: true }));
app.use(express.raw({ limit: "5gb", type: "application/octet-stream" })); 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 { interface SSHSession {
client: SSHClient; client: SSHClient;
isConnected: boolean; isConnected: boolean;
@@ -108,9 +113,19 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
keyPassword, keyPassword,
authType, authType,
credentialId, credentialId,
userId,
} = req.body; } = 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) { if (!sessionId || !ip || !username || !port) {
fileLogger.warn("Missing SSH connection parameters for file manager", { fileLogger.warn("Missing SSH connection parameters for file manager", {
operation: "file_connect", operation: "file_connect",
@@ -2052,9 +2067,21 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
}); });
const PORT = 8084; const PORT = 8084;
app.listen(PORT, () => { app.listen(PORT, async () => {
fileLogger.success("File Manager API server started", { fileLogger.success("File Manager API server started", {
operation: "server_start", operation: "server_start",
port: PORT, 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",
});
}
}); });

View File

@@ -7,6 +7,7 @@ import { sshData, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { statsLogger } from "../utils/logger.js"; import { statsLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
interface PooledConnection { interface PooledConnection {
client: Client; client: Client;
@@ -228,6 +229,7 @@ class MetricsCache {
const connectionPool = new SSHConnectionPool(); const connectionPool = new SSHConnectionPool();
const requestQueue = new RequestQueue(); const requestQueue = new RequestQueue();
const metricsCache = new MetricsCache(); const metricsCache = new MetricsCache();
const authManager = AuthManager.getInstance();
type HostStatus = "online" | "offline"; type HostStatus = "online" | "offline";
@@ -303,19 +305,23 @@ app.use((req, res, next) => {
}); });
app.use(express.json({ limit: "1mb" })); app.use(express.json({ limit: "1mb" }));
// Add authentication middleware - Linus principle: eliminate special cases
app.use(authManager.createAuthMiddleware());
const hostStatuses: Map<number, StatusEntry> = new Map(); const hostStatuses: Map<number, StatusEntry> = new Map();
async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> { async function fetchAllHosts(userId: string): Promise<SSHHostWithCredentials[]> {
try { try {
const hosts = await SimpleDBOps.selectEncrypted( const hosts = await SimpleDBOps.select(
getDb().select().from(sshData), getDb().select().from(sshData).where(eq(sshData.userId, userId)),
"ssh_data", "ssh_data",
userId,
); );
const hostsWithCredentials: SSHHostWithCredentials[] = []; const hostsWithCredentials: SSHHostWithCredentials[] = [];
for (const host of hosts) { for (const host of hosts) {
try { try {
const hostWithCreds = await resolveHostCredentials(host); const hostWithCreds = await resolveHostCredentials(host, userId);
if (hostWithCreds) { if (hostWithCreds) {
hostsWithCredentials.push(hostWithCreds); hostsWithCredentials.push(hostWithCreds);
} }
@@ -335,11 +341,13 @@ async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
async function fetchHostById( async function fetchHostById(
id: number, id: number,
userId: string,
): Promise<SSHHostWithCredentials | undefined> { ): Promise<SSHHostWithCredentials | undefined> {
try { try {
const hosts = await SimpleDBOps.selectEncrypted( const hosts = await SimpleDBOps.select(
getDb().select().from(sshData).where(eq(sshData.id, id)), getDb().select().from(sshData).where(and(eq(sshData.id, id), eq(sshData.userId, userId))),
"ssh_data", "ssh_data",
userId,
); );
if (hosts.length === 0) { if (hosts.length === 0) {
@@ -347,7 +355,7 @@ async function fetchHostById(
} }
const host = hosts[0]; const host = hosts[0];
return await resolveHostCredentials(host); return await resolveHostCredentials(host, userId);
} catch (err) { } catch (err) {
statsLogger.error(`Failed to fetch host ${id}`, err); statsLogger.error(`Failed to fetch host ${id}`, err);
return undefined; return undefined;
@@ -356,6 +364,7 @@ async function fetchHostById(
async function resolveHostCredentials( async function resolveHostCredentials(
host: any, host: any,
userId: string,
): Promise<SSHHostWithCredentials | undefined> { ): Promise<SSHHostWithCredentials | undefined> {
try { try {
const baseHost: any = { const baseHost: any = {
@@ -387,17 +396,18 @@ async function resolveHostCredentials(
if (host.credentialId) { if (host.credentialId) {
try { try {
const credentials = await SimpleDBOps.selectEncrypted( const credentials = await SimpleDBOps.select(
getDb() getDb()
.select() .select()
.from(sshCredentials) .from(sshCredentials)
.where( .where(
and( and(
eq(sshCredentials.id, host.credentialId), eq(sshCredentials.id, host.credentialId),
eq(sshCredentials.userId, host.userId), eq(sshCredentials.userId, userId),
), ),
), ),
"ssh_credentials", "ssh_credentials",
userId,
); );
if (credentials.length > 0) { if (credentials.length > 0) {
@@ -809,11 +819,19 @@ function tcpPing(
}); });
} }
async function pollStatusesOnce(): Promise<void> { async function pollStatusesOnce(userId?: string): Promise<void> {
const hosts = await fetchAllHosts(); if (!userId) {
statsLogger.warn("Skipping status poll - no authenticated user", {
operation: "status_poll",
});
return;
}
const hosts = await fetchAllHosts(userId);
if (hosts.length === 0) { if (hosts.length === 0) {
statsLogger.warn("No hosts retrieved for status polling", { statsLogger.warn("No hosts retrieved for status polling", {
operation: "status_poll", operation: "status_poll",
userId,
}); });
return; return;
} }
@@ -845,8 +863,10 @@ async function pollStatusesOnce(): Promise<void> {
} }
app.get("/status", async (req, res) => { app.get("/status", async (req, res) => {
const userId = (req as any).userId;
if (hostStatuses.size === 0) { if (hostStatuses.size === 0) {
await pollStatusesOnce(); await pollStatusesOnce(userId);
} }
const result: Record<number, StatusEntry> = {}; const result: Record<number, StatusEntry> = {};
for (const [id, entry] of hostStatuses.entries()) { 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) => { app.get("/status/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const userId = (req as any).userId;
try { try {
const host = await fetchHostById(id); const host = await fetchHostById(id, userId);
if (!host) { if (!host) {
return res.status(404).json({ error: "Host not found" }); 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) => { app.post("/refresh", async (req, res) => {
await pollStatusesOnce(); const userId = (req as any).userId;
await pollStatusesOnce(userId);
res.json({ message: "Refreshed" }); res.json({ message: "Refreshed" });
}); });
app.get("/metrics/:id", validateHostId, async (req, res) => { app.get("/metrics/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const userId = (req as any).userId;
try { try {
const host = await fetchHostById(id); const host = await fetchHostById(id, userId);
if (!host) { if (!host) {
return res.status(404).json({ error: "Host not found" }); return res.status(404).json({ error: "Host not found" });
} }
@@ -947,11 +970,21 @@ app.listen(PORT, async () => {
operation: "server_start", operation: "server_start",
port: PORT, port: PORT,
}); });
// Initialize AuthManager for JWT verification
try { try {
await pollStatusesOnce(); await authManager.initialize();
statsLogger.info("AuthManager initialized for metrics collection", {
operation: "auth_init",
});
} catch (err) { } catch (err) {
statsLogger.error("Initial poll failed", err, { statsLogger.error("Failed to initialize AuthManager", err, {
operation: "initial_poll", 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",
});
}); });

View File

@@ -24,24 +24,50 @@ const wss = new WebSocketServer({
const url = parseUrl(info.req.url!, true); const url = parseUrl(info.req.url!, true);
const token = url.query.token as string; 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) { if (!token) {
sshLogger.warn("WebSocket connection rejected: missing token", { sshLogger.warn("WebSocket connection rejected: missing token", {
operation: "websocket_auth_reject", operation: "websocket_auth_reject",
reason: "missing_token", reason: "missing_token",
origin: info.origin, origin: info.origin,
ip: info.req.socket.remoteAddress ip: info.req.socket.remoteAddress,
queryKeys: Object.keys(url.query || {})
}); });
return false; return false;
} }
// Verify JWT token // Verify JWT token
sshLogger.debug("Calling authManager.verifyJWTToken", {
operation: "websocket_jwt_verify",
tokenLength: token.length
});
const payload = await authManager.verifyJWTToken(token); 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) { if (!payload) {
sshLogger.warn("WebSocket connection rejected: invalid token", { sshLogger.warn("WebSocket connection rejected: invalid token", {
operation: "websocket_auth_reject", operation: "websocket_auth_reject",
reason: "invalid_token", reason: "invalid_token",
origin: info.origin, origin: info.origin,
ip: info.req.socket.remoteAddress ip: info.req.socket.remoteAddress,
tokenLength: token.length,
tokenStart: token.substring(0, 20) + "..."
}); });
return false; return false;
} }
@@ -70,9 +96,8 @@ const wss = new WebSocketServer({
return false; return false;
} }
// Attach user info to request object // Note: We don't need to attach user info to request anymore
(info.req as any).userId = payload.userId; // Connection handler will re-verify JWT directly from URL
(info.req as any).userPayload = payload;
sshLogger.info("WebSocket connection authenticated", { sshLogger.info("WebSocket connection authenticated", {
operation: "websocket_auth_success", 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"] features: ["JWT_auth", "connection_limits", "data_access_control"]
}); });
wss.on("connection", (ws: WebSocket, req) => { wss.on("connection", async (ws: WebSocket, req) => {
// Extract authenticated user info from request // Linus principle: eliminate complexity - always parse JWT from URL directly
const userId = (req as any).userId; let userId: string | undefined;
const userPayload = (req as any).userPayload; let userPayload: any;
if (!userId) { try {
sshLogger.error("WebSocket connection without authentication - should not happen", { const url = parseUrl(req.url!, true);
operation: "websocket_security_violation", 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 ip: req.socket.remoteAddress
}); });
ws.close(1008, "Authentication required"); ws.close(1008, "Authentication required");

View File

@@ -1138,24 +1138,6 @@ export function FileManagerGrid({
(f) => f.path === file.path, (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 ( return (
<div <div
key={file.path} key={file.path}

View File

@@ -73,7 +73,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
const [files, setFiles] = useState<FileItem[]>([]); const [files, setFiles] = useState<FileItem[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [sshSessionId, setSshSessionId] = useState<string | null>(null); const [sshSessionId, setSshSessionId] = useState<string | null>(null);
const [currentRequestId, setCurrentRequestId] = useState<number>(0);
const [isReconnecting, setIsReconnecting] = useState<boolean>(false); const [isReconnecting, setIsReconnecting] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0); const [lastRefreshTime, setLastRefreshTime] = useState<number>(0);
@@ -231,56 +230,39 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
} }
} }
async function loadDirectory(path: string) { const loadDirectory = useCallback(async (path: string) => {
if (!sshSessionId) { if (!sshSessionId) {
console.error("Cannot load directory: no SSH session ID"); console.error("Cannot load directory: no SSH session ID");
return; return;
} }
// Generate unique request ID to prevent race conditions
const requestId = Date.now();
setCurrentRequestId(requestId);
setIsLoading(true); setIsLoading(true);
try { try {
console.log(`[${requestId}] Loading directory:`, path); console.log("Loading directory:", path);
const response = await listSSHFiles(sshSessionId, path); const response = await listSSHFiles(sshSessionId, path);
// Only process response if this is still the latest request console.log("Directory response received:", response);
if (requestId !== currentRequestId) {
console.log(`[${requestId}] Request outdated, ignoring response`);
return;
}
console.log(`[${requestId}] Directory response received:`, response);
const files = Array.isArray(response) ? response : response?.files || []; const files = Array.isArray(response) ? response : response?.files || [];
console.log("Directory loaded successfully:", files.length, "items");
setFiles(files); setFiles(files);
clearSelection(); clearSelection();
console.log(`[${requestId}] Directory loaded successfully:`, files.length, "items");
} catch (error: any) { } catch (error: any) {
// Only handle error if this is still the latest request console.error("Failed to load directory:", error);
if (requestId !== currentRequestId) {
console.log(`[${requestId}] Request outdated, ignoring error`);
return;
}
console.error(`[${requestId}] Failed to load directory:`, error);
toast.error( toast.error(
t("fileManager.failedToLoadDirectory") + ": " + (error.message || error) t("fileManager.failedToLoadDirectory") + ": " + (error.message || error)
); );
} finally { } 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 // Debounced refresh function - prevent excessive clicking
function handleRefreshDirectory() { const handleRefreshDirectory = useCallback(() => {
const now = Date.now(); const now = Date.now();
const DEBOUNCE_MS = 500; // 500ms debounce const DEBOUNCE_MS = 500; // 500ms debounce
@@ -291,7 +273,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
setLastRefreshTime(now); setLastRefreshTime(now);
loadDirectory(currentPath); loadDirectory(currentPath);
} }, [currentPath, lastRefreshTime, loadDirectory]);
function handleFilesDropped(fileList: FileList) { function handleFilesDropped(fileList: FileList) {
if (!sshSessionId) { if (!sshSessionId) {
@@ -1465,6 +1447,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
file.name.toLowerCase().includes(searchQuery.toLowerCase()), file.name.toLowerCase().includes(searchQuery.toLowerCase()),
); );
if (!currentHost) { if (!currentHost) {
return ( return (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { import {
FileText, FileText,
Image as ImageIcon, Image as ImageIcon,
@@ -276,6 +277,7 @@ export function FileViewer({
onSave, onSave,
onDownload, onDownload,
}: FileViewerProps) { }: FileViewerProps) {
const { t } = useTranslation();
const [editedContent, setEditedContent] = useState(content); const [editedContent, setEditedContent] = useState(content);
const [originalContent, setOriginalContent] = useState( const [originalContent, setOriginalContent] = useState(
savedContent || content, savedContent || content,