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:
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<number, StatusEntry> = new Map();
|
||||
|
||||
async function fetchAllHosts(): Promise<SSHHostWithCredentials[]> {
|
||||
async function fetchAllHosts(userId: string): Promise<SSHHostWithCredentials[]> {
|
||||
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<SSHHostWithCredentials[]> {
|
||||
|
||||
async function fetchHostById(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<SSHHostWithCredentials | undefined> {
|
||||
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<SSHHostWithCredentials | undefined> {
|
||||
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<void> {
|
||||
const hosts = await fetchAllHosts();
|
||||
async function pollStatusesOnce(userId?: string): Promise<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
app.get("/status", async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (hostStatuses.size === 0) {
|
||||
await pollStatusesOnce();
|
||||
await pollStatusesOnce(userId);
|
||||
}
|
||||
const result: Record<number, StatusEntry> = {};
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
key={file.path}
|
||||
|
||||
@@ -73,7 +73,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
||||
const [currentRequestId, setCurrentRequestId] = useState<number>(0);
|
||||
const [isReconnecting, setIsReconnecting] = useState<boolean>(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
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) {
|
||||
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 (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user