From 21e364733165a05f1ccd6d9cbd0f540d214259de Mon Sep 17 00:00:00 2001 From: catlog22 Date: Thu, 26 Feb 2026 09:56:35 +0800 Subject: [PATCH] feat(security): implement path validation to prevent traversal attacks in session handling --- .../src/components/shared/SessionTimeline.tsx | 15 ++- ccw/frontend/src/hooks/useWebSocket.ts | 45 +------ ccw/src/core/routes/cli-routes.ts | 83 +++++++++++- ccw/src/tools/claude-session-parser.ts | 8 +- ccw/src/tools/cli-executor-state.ts | 8 +- ccw/src/tools/cli-history-store.ts | 20 +-- ccw/src/tools/opencode-session-parser.ts | 127 +++++++++++------- ccw/src/tools/session-content-parser.ts | 21 ++- 8 files changed, 211 insertions(+), 116 deletions(-) diff --git a/ccw/frontend/src/components/shared/SessionTimeline.tsx b/ccw/frontend/src/components/shared/SessionTimeline.tsx index 9678bdb9..77b9ea62 100644 --- a/ccw/frontend/src/components/shared/SessionTimeline.tsx +++ b/ccw/frontend/src/components/shared/SessionTimeline.tsx @@ -187,6 +187,8 @@ function ToolCallPanel({ toolCall, index }: ToolCallPanelProps) { type="button" onClick={() => setIsExpanded(!isExpanded)} className="w-full flex items-center justify-between px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors" + aria-expanded={isExpanded} + aria-controls={`toolcall-panel-${toolCall.name}-${index}`} >
{isExpanded ? ( @@ -194,7 +196,9 @@ function ToolCallPanel({ toolCall, index }: ToolCallPanelProps) { ) : ( )} - {getToolStatusIcon(toolCall.output ? 'completed' : undefined)} + + {getToolStatusIcon(toolCall.output ? 'completed' : undefined)} + {toolCall.name} #{index + 1}
@@ -205,7 +209,10 @@ function ToolCallPanel({ toolCall, index }: ToolCallPanelProps) { {/* Collapsible content */} {isExpanded && ( -
+
{toolCall.arguments && (

@@ -331,7 +338,7 @@ function TurnNode({ turn, isLatest, isLast }: TurnNodeProps) {

    {turn.thoughts.map((thought, i) => ( -
  • {thought}
  • +
  • {thought}
  • ))}
@@ -348,7 +355,7 @@ function TurnNode({ turn, isLatest, isLast }: TurnNodeProps) { ({turn.toolCalls.length})
{turn.toolCalls.map((tc, i) => ( - + ))}
)} diff --git a/ccw/frontend/src/hooks/useWebSocket.ts b/ccw/frontend/src/hooks/useWebSocket.ts index 10f8b9d1..98cfb62c 100644 --- a/ccw/frontend/src/hooks/useWebSocket.ts +++ b/ccw/frontend/src/hooks/useWebSocket.ts @@ -7,7 +7,6 @@ import { useEffect, useRef, useCallback } from 'react'; import { useNotificationStore } from '@/stores'; import { useExecutionStore } from '@/stores/executionStore'; import { useFlowStore } from '@/stores'; -import { useCliStreamStore } from '@/stores/cliStreamStore'; import { useCliSessionStore } from '@/stores/cliSessionStore'; import { handleSessionLockedMessage, @@ -36,7 +35,6 @@ function getStoreState() { const notification = useNotificationStore.getState(); const execution = useExecutionStore.getState(); const flow = useFlowStore.getState(); - const cliStream = useCliStreamStore.getState(); const cliSessions = useCliSessionStore.getState(); return { // Notification store @@ -64,8 +62,6 @@ function getStoreState() { addNodeOutput: execution.addNodeOutput, // Flow store updateNode: flow.updateNode, - // CLI stream store - addOutput: cliStream.addOutput, // CLI session store (PTY-backed terminal) upsertCliSession: cliSessions.upsertSession, @@ -167,18 +163,6 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet // Handle CLI messages if (data.type?.startsWith('CLI_')) { switch (data.type) { - case 'CLI_STARTED': { - const { executionId, tool, mode, timestamp } = data.payload; - - // Add system message for CLI start - stores.addOutput(executionId, { - type: 'system', - content: `[${new Date(timestamp).toLocaleTimeString()}] CLI execution started: ${tool} (${mode || 'default'} mode)`, - timestamp: Date.now(), - }); - break; - } - // ========== PTY CLI Sessions ========== case 'CLI_SESSION_CREATED': { const session = data.payload?.session; @@ -245,7 +229,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet } case 'CLI_OUTPUT': { - const { executionId, chunkType, data: outputData, unit } = data.payload; + const { chunkType, data: outputData, unit } = data.payload; // Handle structured output const unitContent = unit?.content || outputData; @@ -301,33 +285,6 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet } } - // ========== Legacy CLI Stream Output ========== - // Split by lines and add each line to cliStreamStore - const lines = content.split('\n'); - lines.forEach((line: string) => { - // Add non-empty lines, or single line if that's all we have - if (line.trim() || lines.length === 1) { - stores.addOutput(executionId, { - type: unitType as any, - content: line, - timestamp: Date.now(), - }); - } - }); - break; - } - - case 'CLI_COMPLETED': { - const { executionId, success, duration } = data.payload; - - const statusText = success ? 'completed successfully' : 'failed'; - const durationText = duration ? ` (${duration}ms)` : ''; - - stores.addOutput(executionId, { - type: 'system', - content: `[${new Date().toLocaleTimeString()}] CLI execution ${statusText}${durationText}`, - timestamp: Date.now(), - }); break; } } diff --git a/ccw/src/core/routes/cli-routes.ts b/ccw/src/core/routes/cli-routes.ts index 83ea1471..cce4f413 100644 --- a/ccw/src/core/routes/cli-routes.ts +++ b/ccw/src/core/routes/cli-routes.ts @@ -49,6 +49,68 @@ import { getCodeIndexMcp } from '../../tools/claude-cli-tools.js'; import type { RouteContext } from './types.js'; +import { resolve, normalize } from 'path'; +import { homedir } from 'os'; + +// ========== Path Security Utilities ========== +// Allowed directories for session file access (path traversal protection) +const ALLOWED_SESSION_DIRS: string[] = [ + resolve(homedir(), '.claude', 'projects'), + resolve(homedir(), '.local', 'share', 'opencode', 'storage'), + resolve(homedir(), '.gemini', 'sessions'), + resolve(homedir(), '.qwen', 'sessions'), + resolve(homedir(), '.codex') +]; + +/** + * Validates that an absolute path is within one of the allowed directories. + * Prevents path traversal attacks by checking the resolved path. + * + * @param absolutePath - The absolute path to validate + * @param allowedDirs - Array of allowed directory paths + * @returns true if path is within an allowed directory, false otherwise + */ +function isPathWithinAllowedDirs(absolutePath: string, allowedDirs: string[]): boolean { + // Normalize the path to resolve any remaining . or .. sequences + const normalizedPath = normalize(absolutePath); + + // Check if the path starts with any of the allowed directories + for (const allowedDir of allowedDirs) { + const normalizedAllowedDir = normalize(allowedDir); + // Ensure path is within allowed dir (starts with allowedDir + separator) + if (normalizedPath.startsWith(normalizedAllowedDir + '/') || + normalizedPath.startsWith(normalizedAllowedDir + '\\') || + normalizedPath === normalizedAllowedDir) { + return true; + } + } + return false; +} + +/** + * Validates a file path parameter to prevent path traversal attacks. + * Returns validated absolute path or throws an error. + * + * @param inputPath - The user-provided path (may be relative or absolute) + * @param allowedDirs - Array of allowed directory paths + * @returns Object with resolved path or error + */ +function validatePath(inputPath: string, allowedDirs: string[]): { valid: true; path: string } | { valid: false; error: string } { + if (!inputPath || typeof inputPath !== 'string') { + return { valid: false, error: 'Path parameter is required' }; + } + + // Resolve to absolute path (handles relative paths and .. sequences) + const resolvedPath = resolve(inputPath); + + // Validate the resolved path is within allowed directories + if (!isPathWithinAllowedDirs(resolvedPath, allowedDirs)) { + console.warn(`[Security] Path traversal attempt blocked: ${inputPath} resolved to ${resolvedPath}`); + return { valid: false, error: 'Invalid path: access denied' }; + } + + return { valid: true, path: resolvedPath }; +} // ========== Active Executions State ========== // Stores running CLI executions for state recovery when view is opened/refreshed @@ -574,6 +636,16 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { return true; } + // Security: Validate filePath is within allowed session directories + if (filePath) { + const pathValidation = validatePath(filePath, ALLOWED_SESSION_DIRS); + if (!pathValidation.valid) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid path: access denied' })); + return true; + } + } + try { let result; @@ -601,7 +673,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { } } - const session = parseSessionFile(filePath, tool); + const session = await parseSessionFile(filePath, tool); if (!session) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Native session not found at path: ' + filePath })); @@ -788,6 +860,15 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { return { error: 'tool and prompt are required', status: 400 }; } + // Security: Validate toFile path is within project directory + if (toFile) { + const projectDir = resolve(dir || initialPath); + const pathValidation = validatePath(toFile, [projectDir]); + if (!pathValidation.valid) { + return { error: 'Invalid path: access denied', status: 400 }; + } + } + // Generate smart context if enabled let finalPrompt = prompt; if (smartContext?.enabled) { diff --git a/ccw/src/tools/claude-session-parser.ts b/ccw/src/tools/claude-session-parser.ts index 3ee32de3..26297228 100644 --- a/ccw/src/tools/claude-session-parser.ts +++ b/ccw/src/tools/claude-session-parser.ts @@ -162,8 +162,8 @@ export function parseClaudeSession(filePath: string): ParsedSession | null { if (entry.timestamp) { lastUpdated = entry.timestamp; } - } catch { - // Skip invalid lines + } catch (e) { + console.warn('[claude-session-parser] Failed to parse JSON line:', e instanceof Error ? e.message : String(e)); } } @@ -426,8 +426,8 @@ export function parseClaudeSessionContent(content: string, filePath?: string): P if (entry.timestamp) { lastUpdated = entry.timestamp; } - } catch { - // Skip invalid lines + } catch (e) { + console.warn('[claude-session-parser] Failed to parse JSON line in parseClaudeSessionContent:', e instanceof Error ? e.message : String(e)); } } diff --git a/ccw/src/tools/cli-executor-state.ts b/ccw/src/tools/cli-executor-state.ts index e782b712..e216b91a 100644 --- a/ccw/src/tools/cli-executor-state.ts +++ b/ccw/src/tools/cli-executor-state.ts @@ -447,7 +447,7 @@ export function getLatestExecution(baseDir: string, tool?: string): ExecutionRec */ export async function getNativeSessionContent(baseDir: string, ccwId: string) { const store = await getSqliteStore(baseDir); - return store.getNativeSessionContent(ccwId); + return await store.getNativeSessionContent(ccwId); } /** @@ -460,7 +460,7 @@ export async function getFormattedNativeConversation(baseDir: string, ccwId: str maxContentLength?: number; }) { const store = await getSqliteStore(baseDir); - return store.getFormattedNativeConversation(ccwId, options); + return await store.getFormattedNativeConversation(ccwId, options); } /** @@ -468,7 +468,7 @@ export async function getFormattedNativeConversation(baseDir: string, ccwId: str */ export async function getNativeConversationPairs(baseDir: string, ccwId: string) { const store = await getSqliteStore(baseDir); - return store.getNativeConversationPairs(ccwId); + return await store.getNativeConversationPairs(ccwId); } /** @@ -476,7 +476,7 @@ export async function getNativeConversationPairs(baseDir: string, ccwId: string) */ export async function getEnrichedConversation(baseDir: string, ccwId: string) { const store = await getSqliteStore(baseDir); - return store.getEnrichedConversation(ccwId); + return await store.getEnrichedConversation(ccwId); } /** diff --git a/ccw/src/tools/cli-history-store.ts b/ccw/src/tools/cli-history-store.ts index fe7714e9..9ce746d9 100644 --- a/ccw/src/tools/cli-history-store.ts +++ b/ccw/src/tools/cli-history-store.ts @@ -1063,7 +1063,7 @@ export class CliHistoryStore { * Get parsed native session content by CCW ID * Returns full conversation with all turns from native session file */ - getNativeSessionContent(ccwId: string): ParsedSession | null { + async getNativeSessionContent(ccwId: string): Promise { const mapping = this.getNativeSessionMapping(ccwId); if (!mapping || !mapping.native_session_path) { return null; @@ -1075,13 +1075,13 @@ export class CliHistoryStore { /** * Get formatted conversation text from native session */ - getFormattedNativeConversation(ccwId: string, options?: { + async getFormattedNativeConversation(ccwId: string, options?: { includeThoughts?: boolean; includeToolCalls?: boolean; includeTokens?: boolean; maxContentLength?: number; - }): string | null { - const session = this.getNativeSessionContent(ccwId); + }): Promise { + const session = await this.getNativeSessionContent(ccwId); if (!session) { return null; } @@ -1091,13 +1091,13 @@ export class CliHistoryStore { /** * Get conversation pairs (user prompt + assistant response) from native session */ - getNativeConversationPairs(ccwId: string): Array<{ + async getNativeConversationPairs(ccwId: string): Promise | null { - const session = this.getNativeSessionContent(ccwId); + }> | null> { + const session = await this.getNativeSessionContent(ccwId); if (!session) { return null; } @@ -1108,7 +1108,7 @@ export class CliHistoryStore { * Get conversation with enriched native session data * Merges CCW history with native session content */ - getEnrichedConversation(ccwId: string): { + async getEnrichedConversation(ccwId: string): Promise<{ ccw: ConversationRecord | null; native: ParsedSession | null; merged: Array<{ @@ -1121,9 +1121,9 @@ export class CliHistoryStore { nativeThoughts?: string[]; nativeToolCalls?: Array<{ name: string; arguments?: string; output?: string }>; }>; - } | null { + } | null> { const ccwConv = this.getConversation(ccwId); - const nativeSession = this.getNativeSessionContent(ccwId); + const nativeSession = await this.getNativeSessionContent(ccwId); if (!ccwConv && !nativeSession) { return null; diff --git a/ccw/src/tools/opencode-session-parser.ts b/ccw/src/tools/opencode-session-parser.ts index 04c71a56..cea455e5 100644 --- a/ccw/src/tools/opencode-session-parser.ts +++ b/ccw/src/tools/opencode-session-parser.ts @@ -7,8 +7,8 @@ * part//.json - Message parts (text, tool, reasoning, step-start) */ -import { readFileSync, existsSync, readdirSync, statSync } from 'fs'; -import { join, dirname } from 'path'; +import { readFile, access, readdir, stat } from 'fs/promises'; +import { join } from 'path'; import type { ParsedSession, ParsedTurn, ToolCallInfo, TokenInfo } from './session-content-parser.js'; // ============================================================ @@ -111,32 +111,45 @@ export function getOpenCodeStoragePath(): string { } /** - * Read JSON file safely + * Check if a path exists (async) */ -function readJsonFile(filePath: string): T | null { +async function pathExists(filePath: string): Promise { try { - if (!existsSync(filePath)) { + await access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Read JSON file safely (async) + */ +async function readJsonFile(filePath: string): Promise { + try { + if (!(await pathExists(filePath))) { return null; } - const content = readFileSync(filePath, 'utf8'); + const content = await readFile(filePath, 'utf8'); return JSON.parse(content) as T; - } catch { + } catch (error) { + console.warn(`[OpenCodeParser] Failed to read JSON file ${filePath}:`, error); return null; } } /** - * Get all JSON files in a directory sorted by name (which includes timestamp) + * Get all JSON files in a directory sorted by name (which includes timestamp) (async) */ -function getJsonFilesInDir(dirPath: string): string[] { - if (!existsSync(dirPath)) { +async function getJsonFilesInDir(dirPath: string): Promise { + if (!(await pathExists(dirPath))) { return []; } try { - return readdirSync(dirPath) - .filter(f => f.endsWith('.json')) - .sort(); - } catch { + const files = await readdir(dirPath); + return files.filter(f => f.endsWith('.json')).sort(); + } catch (error) { + console.warn(`[OpenCodeParser] Failed to read directory ${dirPath}:`, error); return []; } } @@ -159,12 +172,12 @@ function formatTimestamp(ms: number): string { * @param storageBasePath - Optional base path to storage (auto-detected if not provided) * @returns ParsedSession with aggregated turns from messages and parts */ -export function parseOpenCodeSession( +export async function parseOpenCodeSession( sessionPath: string, storageBasePath?: string -): ParsedSession | null { +): Promise { // Read session file - const session = readJsonFile(sessionPath); + const session = await readJsonFile(sessionPath); if (!session) { return null; } @@ -175,7 +188,7 @@ export function parseOpenCodeSession( // Read all messages for this session const messageDir = join(basePath, 'message', sessionId); - const messageFiles = getJsonFilesInDir(messageDir); + const messageFiles = await getJsonFilesInDir(messageDir); if (messageFiles.length === 0) { // Return session with no turns @@ -199,16 +212,16 @@ export function parseOpenCodeSession( }> = []; for (const msgFile of messageFiles) { - const message = readJsonFile(join(messageDir, msgFile)); + const message = await readJsonFile(join(messageDir, msgFile)); if (!message) continue; // Read all parts for this message const partDir = join(basePath, 'part', message.id); - const partFiles = getJsonFilesInDir(partDir); + const partFiles = await getJsonFilesInDir(partDir); const parts: OpenCodePart[] = []; for (const partFile of partFiles) { - const part = readJsonFile(join(partDir, partFile)); + const part = await readJsonFile(join(partDir, partFile)); if (part) { parts.push(part); } @@ -333,14 +346,14 @@ function buildTurns(messages: Array<{ message: OpenCodeMessage; parts: OpenCodeP * @param projectHash - Optional project hash (will search all projects if not provided) * @returns ParsedSession or null if not found */ -export function parseOpenCodeSessionById( +export async function parseOpenCodeSessionById( sessionId: string, projectHash?: string -): ParsedSession | null { +): Promise { const basePath = getOpenCodeStoragePath(); const sessionDir = join(basePath, 'session'); - if (!existsSync(sessionDir)) { + if (!(await pathExists(sessionDir))) { return null; } @@ -352,19 +365,29 @@ export function parseOpenCodeSessionById( // Search all project directories try { - const projectDirs = readdirSync(sessionDir).filter(d => { - const fullPath = join(sessionDir, d); - return statSync(fullPath).isDirectory(); - }); + const entries = await readdir(sessionDir); + const projectDirs: string[] = []; + + for (const entry of entries) { + const fullPath = join(sessionDir, entry); + try { + const entryStat = await stat(fullPath); + if (entryStat.isDirectory()) { + projectDirs.push(entry); + } + } catch { + // Skip entries that can't be stat'd + } + } for (const projHash of projectDirs) { const sessionPath = join(sessionDir, projHash, `${sessionId}.json`); - if (existsSync(sessionPath)) { + if (await pathExists(sessionPath)) { return parseOpenCodeSession(sessionPath, basePath); } } - } catch { - // Ignore errors + } catch (error) { + console.warn('[OpenCodeParser] Failed to search project directories:', error); } return null; @@ -376,14 +399,14 @@ export function parseOpenCodeSessionById( * @param projectHash - Project hash to filter by * @returns Array of session info (not full parsed sessions) */ -export function getOpenCodeSessions(projectHash?: string): Array<{ +export async function getOpenCodeSessions(projectHash?: string): Promise { +}>> { const basePath = getOpenCodeStoragePath(); const sessionDir = join(basePath, 'session'); const sessions: Array<{ @@ -395,27 +418,41 @@ export function getOpenCodeSessions(projectHash?: string): Array<{ updatedAt: Date; }> = []; - if (!existsSync(sessionDir)) { + if (!(await pathExists(sessionDir))) { return sessions; } try { - const projectDirs = projectHash - ? [projectHash] - : readdirSync(sessionDir).filter(d => { - const fullPath = join(sessionDir, d); - return statSync(fullPath).isDirectory(); - }); + let projectDirs: string[]; + + if (projectHash) { + projectDirs = [projectHash]; + } else { + const entries = await readdir(sessionDir); + projectDirs = []; + + for (const entry of entries) { + const fullPath = join(sessionDir, entry); + try { + const entryStat = await stat(fullPath); + if (entryStat.isDirectory()) { + projectDirs.push(entry); + } + } catch { + // Skip entries that can't be stat'd + } + } + } for (const projHash of projectDirs) { const projDir = join(sessionDir, projHash); - if (!existsSync(projDir)) continue; + if (!(await pathExists(projDir))) continue; - const sessionFiles = getJsonFilesInDir(projDir); + const sessionFiles = await getJsonFilesInDir(projDir); for (const sessionFile of sessionFiles) { const filePath = join(projDir, sessionFile); - const session = readJsonFile(filePath); + const session = await readJsonFile(filePath); if (session) { sessions.push({ @@ -429,8 +466,8 @@ export function getOpenCodeSessions(projectHash?: string): Array<{ } } } - } catch { - // Ignore errors + } catch (error) { + console.warn('[OpenCodeParser] Failed to get sessions:', error); } // Sort by updated time descending diff --git a/ccw/src/tools/session-content-parser.ts b/ccw/src/tools/session-content-parser.ts index bac69552..f942d1a5 100644 --- a/ccw/src/tools/session-content-parser.ts +++ b/ccw/src/tools/session-content-parser.ts @@ -4,6 +4,7 @@ */ import { readFileSync, existsSync } from 'fs'; +import { readFile, access } from 'fs/promises'; import { parseClaudeSession } from './claude-session-parser.js'; import { parseOpenCodeSession } from './opencode-session-parser.js'; @@ -178,15 +179,27 @@ function isJSONL(content: string): boolean { } /** - * Parse a native session file and return standardized conversation data + * Check if a path exists (async) */ -export function parseSessionFile(filePath: string, tool: string): ParsedSession | null { - if (!existsSync(filePath)) { +async function pathExists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Parse a native session file and return standardized conversation data (async) + */ +export async function parseSessionFile(filePath: string, tool: string): Promise { + if (!(await pathExists(filePath))) { return null; } try { - const content = readFileSync(filePath, 'utf8'); + const content = await readFile(filePath, 'utf8'); switch (tool) { case 'gemini':