mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 12:21:20 +00:00
fix(dashboard): resolve 6 critical security and UX edge cases
- TOCTOU symlink attack: use file descriptor for atomic read in persist-routes - Non-atomic mutex: add RestoreMutex class with Promise queue pattern - Restore confirmation: add AlertDialog before destructive backup restore - Sensitive data exposure: sanitize auth_token/management_key in debug console - Chunk retry: add lazyWithRetry wrapper with exponential backoff (3 retries) - URL tab case sensitivity: normalize tab param with toLowerCase()
This commit is contained in:
@@ -15,8 +15,36 @@ interface BackupFile {
|
|||||||
date: Date;
|
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<boolean> {
|
||||||
|
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 */
|
/** Get Claude settings.json path */
|
||||||
function getClaudeSettingsPath(): string {
|
function getClaudeSettingsPath(): string {
|
||||||
@@ -87,15 +115,14 @@ router.get('/backups', (_req: Request, res: Response): void => {
|
|||||||
* POST /api/persist/restore - Restore from a backup
|
* POST /api/persist/restore - Restore from a backup
|
||||||
* Body: { timestamp?: string } - If not provided, restores latest
|
* Body: { timestamp?: string } - If not provided, restores latest
|
||||||
*/
|
*/
|
||||||
router.post('/restore', (req: Request, res: Response): void => {
|
router.post('/restore', async (req: Request, res: Response): Promise<void> => {
|
||||||
// Check concurrent restore lock
|
// Atomic mutex acquisition - prevents race conditions
|
||||||
if (restoreLock) {
|
const acquired = await restoreMutex.acquire();
|
||||||
|
if (!acquired) {
|
||||||
res.status(409).json({ error: 'Restore already in progress' });
|
res.status(409).json({ error: 'Restore already in progress' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreLock = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { timestamp } = req.body;
|
const { timestamp } = req.body;
|
||||||
const backups = getBackupFiles();
|
const backups = getBackupFiles();
|
||||||
@@ -118,7 +145,7 @@ router.post('/restore', (req: Request, res: Response): void => {
|
|||||||
backup = found;
|
backup = found;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Security checks
|
// Security: reject symlinks to prevent path traversal attacks
|
||||||
if (isSymlink(backup.path)) {
|
if (isSymlink(backup.path)) {
|
||||||
res.status(400).json({ error: 'Backup file is a symlink - refusing for security' });
|
res.status(400).json({ error: 'Backup file is a symlink - refusing for security' });
|
||||||
return;
|
return;
|
||||||
@@ -130,29 +157,57 @@ router.post('/restore', (req: Request, res: Response): void => {
|
|||||||
return;
|
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 backupContent: string;
|
||||||
|
let fd: number | undefined;
|
||||||
try {
|
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);
|
const parsed = JSON.parse(backupContent);
|
||||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||||
res.status(400).json({ error: 'Backup file is corrupted' });
|
res.status(400).json({ error: 'Backup file is corrupted' });
|
||||||
return;
|
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' });
|
res.status(400).json({ error: 'Backup file is corrupted or invalid JSON' });
|
||||||
return;
|
return;
|
||||||
|
} finally {
|
||||||
|
if (fd !== undefined) {
|
||||||
|
try {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
} catch {
|
||||||
|
// Ignore close errors
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic restore with rollback capability
|
// Atomic restore with rollback capability
|
||||||
const settingsDir = path.dirname(settingsPath);
|
const settingsDir = path.dirname(settingsPath);
|
||||||
const tempPath = path.join(settingsDir, 'settings.json.restore-tmp');
|
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 {
|
try {
|
||||||
// Step 1: Backup current settings for rollback
|
// Step 1: Backup current settings for rollback
|
||||||
if (fs.existsSync(settingsPath)) {
|
if (fs.existsSync(settingsPath)) {
|
||||||
fs.copyFileSync(settingsPath, backupPath);
|
fs.copyFileSync(settingsPath, rollbackPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Write validated content to temp file
|
// Step 2: Write validated content to temp file
|
||||||
@@ -162,8 +217,8 @@ router.post('/restore', (req: Request, res: Response): void => {
|
|||||||
fs.renameSync(tempPath, settingsPath);
|
fs.renameSync(tempPath, settingsPath);
|
||||||
|
|
||||||
// Step 4: Cleanup rollback backup on success
|
// Step 4: Cleanup rollback backup on success
|
||||||
if (fs.existsSync(backupPath)) {
|
if (fs.existsSync(rollbackPath)) {
|
||||||
fs.unlinkSync(backupPath);
|
fs.unlinkSync(rollbackPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -174,21 +229,25 @@ router.post('/restore', (req: Request, res: Response): void => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Rollback on failure
|
// Rollback on failure
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(backupPath)) {
|
if (fs.existsSync(rollbackPath)) {
|
||||||
fs.renameSync(backupPath, settingsPath);
|
fs.renameSync(rollbackPath, settingsPath);
|
||||||
}
|
}
|
||||||
if (fs.existsSync(tempPath)) {
|
if (fs.existsSync(tempPath)) {
|
||||||
fs.unlinkSync(tempPath);
|
fs.unlinkSync(tempPath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (rollbackErr) {
|
||||||
// Ignore cleanup errors
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: (error as Error).message });
|
res.status(500).json({ error: (error as Error).message });
|
||||||
} finally {
|
} finally {
|
||||||
restoreLock = false;
|
restoreMutex.release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import type { SettingsTab } from '../types';
|
|||||||
|
|
||||||
export function useSettingsTab() {
|
export function useSettingsTab() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
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 =
|
const activeTab: SettingsTab =
|
||||||
tabParam === 'globalenv'
|
tabParam === 'globalenv'
|
||||||
? 'globalenv'
|
? 'globalenv'
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
* Main entry point with lazy-loaded sections and URL tab persistence
|
* 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 { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { RefreshCw, FileCode, Copy, Check, GripVertical, AlertCircle } from 'lucide-react';
|
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 { SectionSkeleton } from './components/section-skeleton';
|
||||||
import type { SettingsTab } from './types';
|
import type { SettingsTab } from './types';
|
||||||
|
|
||||||
// Lazy-loaded sections
|
/**
|
||||||
const WebSearchSection = lazy(() => import('./sections/websearch'));
|
* Retry wrapper for dynamic imports with exponential backoff
|
||||||
const GlobalEnvSection = lazy(() => import('./sections/globalenv-section'));
|
* Handles temporary network failures gracefully
|
||||||
const ProxySection = lazy(() => import('./sections/proxy'));
|
*/
|
||||||
const AuthSection = lazy(() => import('./sections/auth-section'));
|
function retryImport<T extends ComponentType<unknown>>(
|
||||||
const BackupsSection = lazy(() => import('./sections/backups-section'));
|
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<T extends ComponentType<unknown>>(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
|
// Error Boundary for lazy-loaded sections
|
||||||
class SectionErrorBoundary extends Component<
|
class SectionErrorBoundary extends Component<
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 { RefreshCw, CheckCircle2, AlertCircle, RotateCcw, Clock, Archive } from 'lucide-react';
|
||||||
import { useRawConfig } from '../hooks';
|
import { useRawConfig } from '../hooks';
|
||||||
|
|
||||||
@@ -35,6 +45,7 @@ export default function BackupsSection() {
|
|||||||
const [restoring, setRestoring] = useState<string | null>(null);
|
const [restoring, setRestoring] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
const [confirmRestore, setConfirmRestore] = useState<string | null>(null); // Confirmation dialog state
|
||||||
|
|
||||||
// Fetch backups
|
// Fetch backups
|
||||||
const fetchBackups = useCallback(async () => {
|
const fetchBackups = useCallback(async () => {
|
||||||
@@ -226,7 +237,7 @@ export default function BackupsSection() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => restoreBackup(backup.timestamp)}
|
onClick={() => setConfirmRestore(backup.timestamp)}
|
||||||
disabled={restoring !== null}
|
disabled={restoring !== null}
|
||||||
className="gap-2 shrink-0"
|
className="gap-2 shrink-0"
|
||||||
>
|
>
|
||||||
@@ -259,6 +270,35 @@ export default function BackupsSection() {
|
|||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Restore Confirmation Dialog */}
|
||||||
|
<AlertDialog open={!!confirmRestore} onOpenChange={() => setConfirmRestore(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Restore Backup?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will replace your current settings with backup from{' '}
|
||||||
|
<code className="font-mono bg-muted px-1.5 py-0.5 rounded text-foreground">
|
||||||
|
{confirmRestore}
|
||||||
|
</code>
|
||||||
|
. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
if (confirmRestore) {
|
||||||
|
restoreBackup(confirmRestore);
|
||||||
|
}
|
||||||
|
setConfirmRestore(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,10 +60,19 @@ export default function ProxySection() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Log when debug mode changes
|
// Log when debug mode changes (sanitize sensitive fields)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debugMode) {
|
if (debugMode && config) {
|
||||||
console.log('[CCS Debug] Debug mode enabled - proxy config:', 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]);
|
}, [debugMode, config]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user