diff --git a/src/web-server/routes/persist-routes.ts b/src/web-server/routes/persist-routes.ts index b3e9bbce..576fbb0f 100644 --- a/src/web-server/routes/persist-routes.ts +++ b/src/web-server/routes/persist-routes.ts @@ -15,8 +15,36 @@ interface BackupFile { date: Date; } -/** Simple in-memory mutex for restore operations */ -let restoreLock = false; +/** + * Async mutex for restore operations - prevents race conditions + * Uses a Promise queue pattern for atomic lock acquisition + */ +class RestoreMutex { + private locked = false; + private queue: Array<() => void> = []; + + async acquire(): Promise { + if (this.locked) { + // Already locked - add to queue and wait + return new Promise((resolve) => { + this.queue.push(() => resolve(false)); // Return false = was queued, reject + }); + } + this.locked = true; + return true; + } + + release(): void { + const next = this.queue.shift(); + if (next) { + next(); // Signal queued request to fail + } else { + this.locked = false; + } + } +} + +const restoreMutex = new RestoreMutex(); /** Get Claude settings.json path */ function getClaudeSettingsPath(): string { @@ -87,15 +115,14 @@ router.get('/backups', (_req: Request, res: Response): void => { * POST /api/persist/restore - Restore from a backup * Body: { timestamp?: string } - If not provided, restores latest */ -router.post('/restore', (req: Request, res: Response): void => { - // Check concurrent restore lock - if (restoreLock) { +router.post('/restore', async (req: Request, res: Response): Promise => { + // Atomic mutex acquisition - prevents race conditions + const acquired = await restoreMutex.acquire(); + if (!acquired) { res.status(409).json({ error: 'Restore already in progress' }); return; } - restoreLock = true; - try { const { timestamp } = req.body; const backups = getBackupFiles(); @@ -118,7 +145,7 @@ router.post('/restore', (req: Request, res: Response): void => { backup = found; } - // Security checks + // Security: reject symlinks to prevent path traversal attacks if (isSymlink(backup.path)) { res.status(400).json({ error: 'Backup file is a symlink - refusing for security' }); return; @@ -130,29 +157,57 @@ router.post('/restore', (req: Request, res: Response): void => { return; } - // Read and validate backup JSON (do this once atomically) + // Read backup content securely using file descriptor to prevent TOCTOU + // Open with O_NOFOLLOW equivalent check then read atomically let backupContent: string; + let fd: number | undefined; try { - backupContent = fs.readFileSync(backup.path, 'utf8'); + // Verify not symlink immediately before open + const stats = fs.lstatSync(backup.path); + if (stats.isSymbolicLink()) { + res + .status(400) + .json({ error: 'Backup became symlink during read - refusing for security' }); + return; + } + // Open file descriptor for atomic read + fd = fs.openSync(backup.path, 'r'); + const buffer = Buffer.alloc(stats.size); + fs.readSync(fd, buffer, 0, stats.size, 0); + backupContent = buffer.toString('utf8'); + const parsed = JSON.parse(backupContent); if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { res.status(400).json({ error: 'Backup file is corrupted' }); return; } - } catch { + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ENOENT') { + res.status(404).json({ error: 'Backup was deleted during restore' }); + return; + } res.status(400).json({ error: 'Backup file is corrupted or invalid JSON' }); return; + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch { + // Ignore close errors + } + } } // Atomic restore with rollback capability const settingsDir = path.dirname(settingsPath); const tempPath = path.join(settingsDir, 'settings.json.restore-tmp'); - const backupPath = path.join(settingsDir, 'settings.json.rollback-tmp'); + const rollbackPath = path.join(settingsDir, 'settings.json.rollback-tmp'); try { // Step 1: Backup current settings for rollback if (fs.existsSync(settingsPath)) { - fs.copyFileSync(settingsPath, backupPath); + fs.copyFileSync(settingsPath, rollbackPath); } // Step 2: Write validated content to temp file @@ -162,8 +217,8 @@ router.post('/restore', (req: Request, res: Response): void => { fs.renameSync(tempPath, settingsPath); // Step 4: Cleanup rollback backup on success - if (fs.existsSync(backupPath)) { - fs.unlinkSync(backupPath); + if (fs.existsSync(rollbackPath)) { + fs.unlinkSync(rollbackPath); } res.json({ @@ -174,21 +229,25 @@ router.post('/restore', (req: Request, res: Response): void => { } catch (error) { // Rollback on failure try { - if (fs.existsSync(backupPath)) { - fs.renameSync(backupPath, settingsPath); + if (fs.existsSync(rollbackPath)) { + fs.renameSync(rollbackPath, settingsPath); } if (fs.existsSync(tempPath)) { fs.unlinkSync(tempPath); } - } catch { - // Ignore cleanup errors + } catch (rollbackErr) { + console.error('[persist-routes] Rollback failed:', rollbackErr); + res.status(500).json({ + error: 'Restore failed and rollback unsuccessful - manual recovery may be needed', + }); + return; } throw error; } } catch (error) { res.status(500).json({ error: (error as Error).message }); } finally { - restoreLock = false; + restoreMutex.release(); } }); diff --git a/ui/src/pages/settings/hooks/use-settings-tab.ts b/ui/src/pages/settings/hooks/use-settings-tab.ts index 1a4f9192..8644d45a 100644 --- a/ui/src/pages/settings/hooks/use-settings-tab.ts +++ b/ui/src/pages/settings/hooks/use-settings-tab.ts @@ -8,7 +8,8 @@ import type { SettingsTab } from '../types'; export function useSettingsTab() { const [searchParams, setSearchParams] = useSearchParams(); - const tabParam = searchParams.get('tab'); + // Normalize to lowercase for case-insensitive matching (fixes ?tab=Backups vs ?tab=backups) + const tabParam = searchParams.get('tab')?.toLowerCase(); const activeTab: SettingsTab = tabParam === 'globalenv' ? 'globalenv' diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx index abfd6529..79806c9c 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -3,7 +3,15 @@ * Main entry point with lazy-loaded sections and URL tab persistence */ -import { lazy, Suspense, startTransition, useEffect, Component, type ReactNode } from 'react'; +import { + lazy, + Suspense, + startTransition, + useEffect, + Component, + type ReactNode, + type ComponentType, +} from 'react'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { Button } from '@/components/ui/button'; import { RefreshCw, FileCode, Copy, Check, GripVertical, AlertCircle } from 'lucide-react'; @@ -14,12 +22,34 @@ import { TabNavigation } from './components/tab-navigation'; import { SectionSkeleton } from './components/section-skeleton'; import type { SettingsTab } from './types'; -// Lazy-loaded sections -const WebSearchSection = lazy(() => import('./sections/websearch')); -const GlobalEnvSection = lazy(() => import('./sections/globalenv-section')); -const ProxySection = lazy(() => import('./sections/proxy')); -const AuthSection = lazy(() => import('./sections/auth-section')); -const BackupsSection = lazy(() => import('./sections/backups-section')); +/** + * Retry wrapper for dynamic imports with exponential backoff + * Handles temporary network failures gracefully + */ +function retryImport>( + importFn: () => Promise<{ default: T }>, + retries = 3, + delay = 1000 +): Promise<{ default: T }> { + return importFn().catch((error: Error) => { + if (retries <= 0) throw error; + return new Promise((resolve) => setTimeout(resolve, delay)).then(() => + retryImport(importFn, retries - 1, delay * 2) + ); + }); +} + +/** Lazy load with automatic retry on failure */ +function lazyWithRetry>(importFn: () => Promise<{ default: T }>) { + return lazy(() => retryImport(importFn)); +} + +// Lazy-loaded sections with retry capability +const WebSearchSection = lazyWithRetry(() => import('./sections/websearch')); +const GlobalEnvSection = lazyWithRetry(() => import('./sections/globalenv-section')); +const ProxySection = lazyWithRetry(() => import('./sections/proxy')); +const AuthSection = lazyWithRetry(() => import('./sections/auth-section')); +const BackupsSection = lazyWithRetry(() => import('./sections/backups-section')); // Error Boundary for lazy-loaded sections class SectionErrorBoundary extends Component< diff --git a/ui/src/pages/settings/sections/backups-section.tsx b/ui/src/pages/settings/sections/backups-section.tsx index 3a9985a7..28153aee 100644 --- a/ui/src/pages/settings/sections/backups-section.tsx +++ b/ui/src/pages/settings/sections/backups-section.tsx @@ -10,6 +10,16 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Card } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { Badge } from '@/components/ui/badge'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { RefreshCw, CheckCircle2, AlertCircle, RotateCcw, Clock, Archive } from 'lucide-react'; import { useRawConfig } from '../hooks'; @@ -35,6 +45,7 @@ export default function BackupsSection() { const [restoring, setRestoring] = useState(null); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); + const [confirmRestore, setConfirmRestore] = useState(null); // Confirmation dialog state // Fetch backups const fetchBackups = useCallback(async () => { @@ -226,7 +237,7 @@ export default function BackupsSection() { + + {/* Restore Confirmation Dialog */} + setConfirmRestore(null)}> + + + Restore Backup? + + This will replace your current settings with backup from{' '} + + {confirmRestore} + + . This action cannot be undone. + + + + Cancel + { + if (confirmRestore) { + restoreBackup(confirmRestore); + } + setConfirmRestore(null); + }} + > + Restore + + + + ); } diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index ab9d8ef5..76dfe445 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -60,10 +60,19 @@ export default function ProxySection() { } }; - // Log when debug mode changes + // Log when debug mode changes (sanitize sensitive fields) useEffect(() => { - if (debugMode) { - console.log('[CCS Debug] Debug mode enabled - proxy config:', config); + if (debugMode && config) { + // Sanitize config before logging to prevent credential exposure + const sanitizedConfig = { + ...config, + remote: { + ...config.remote, + auth_token: config.remote.auth_token ? '[REDACTED]' : undefined, + management_key: config.remote.management_key ? '[REDACTED]' : undefined, + }, + }; + console.log('[CCS Debug] Debug mode enabled - proxy config:', sanitizedConfig); } }, [debugMode, config]);