diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts index 4dcf1a5b..507270e5 100644 --- a/src/web-server/shared-routes.ts +++ b/src/web-server/shared-routes.ts @@ -13,6 +13,10 @@ import { getClaudeConfigDir } from '../utils/claude-config-path'; export const sharedRoutes = Router(); +const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10; +const MAX_DESCRIPTION_LENGTH = 140; +const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB + interface SharedItem { name: string; description: string; @@ -222,16 +226,19 @@ interface MarkdownFileEntry { } function collectMarkdownFiles(sharedDir: string, allowedRoots: Set): MarkdownFileEntry[] { - const directoriesToVisit = [sharedDir]; + const directoriesToVisit: Array<{ path: string; depth: number }> = [ + { path: sharedDir, depth: 0 }, + ]; const visitedDirectories = new Set(); const markdownFiles: MarkdownFileEntry[] = []; while (directoriesToVisit.length > 0) { - const currentDir = directoriesToVisit.pop(); - if (!currentDir) { + const current = directoriesToVisit.pop(); + if (!current) { continue; } + const currentDir = current.path; const resolvedCurrentDir = safeRealPath(currentDir); if (!resolvedCurrentDir || !isPathWithinAny(resolvedCurrentDir, allowedRoots)) { continue; @@ -265,7 +272,9 @@ function collectMarkdownFiles(sharedDir: string, allowedRoots: Set): Mar } if (stats.isDirectory()) { - directoriesToVisit.push(entryPath); + if (current.depth < MAX_DIRECTORY_TRAVERSAL_DEPTH) { + directoriesToVisit.push({ path: entryPath, depth: current.depth + 1 }); + } continue; } @@ -324,12 +333,11 @@ function stripFrontmatter(content: string): string { } function trimDescription(description: string): string { - const maxLength = 140; - if (description.length <= maxLength) { + if (description.length <= MAX_DESCRIPTION_LENGTH) { return description; } - return `${description.slice(0, maxLength - 3).trimEnd()}...`; + return `${description.slice(0, MAX_DESCRIPTION_LENGTH - 3).trimEnd()}...`; } function readFirstMarkdownDescription( @@ -357,6 +365,9 @@ function readMarkdownDescription(markdownPath: string, allowedRoots: Set if (!stats.isFile()) { return null; } + if (stats.size > MAX_MARKDOWN_FILE_BYTES) { + return null; + } const content = fs.readFileSync(resolvedMarkdownPath, 'utf8'); return extractDescription(content); } catch { diff --git a/tests/unit/web-server/shared-routes.test.ts b/tests/unit/web-server/shared-routes.test.ts index 458bd6f8..7f8dbfd9 100644 --- a/tests/unit/web-server/shared-routes.test.ts +++ b/tests/unit/web-server/shared-routes.test.ts @@ -231,6 +231,45 @@ describe('web-server shared-routes', () => { ]); }); + it('skips markdown files deeper than traversal depth limit', async () => { + const commandsDir = path.join(ccsDir, 'shared', 'commands'); + let currentDir = commandsDir; + for (let index = 0; index < 11; index += 1) { + currentDir = path.join(currentDir, `depth-${index}`); + fs.mkdirSync(currentDir, { recursive: true }); + } + fs.writeFileSync(path.join(currentDir, 'too-deep.md'), 'This should be ignored'); + + const payload = await getJson<{ items: Array<{ name: string }> }>( + baseUrl, + '/api/shared/commands' + ); + + expect(payload.items).toEqual([]); + }); + + it('skips oversized markdown files when extracting descriptions', async () => { + const commandsDir = path.join(ccsDir, 'shared', 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + + const oversizedBody = `# Big\n\n${'x'.repeat(1024 * 1024)}`; + fs.writeFileSync(path.join(commandsDir, 'oversized.md'), oversizedBody); + fs.writeFileSync(path.join(commandsDir, 'safe.md'), 'Safe command description'); + + const payload = await getJson<{ + items: Array<{ name: string; type: string; description: string; path: string }>; + }>(baseUrl, '/api/shared/commands'); + + expect(payload.items).toEqual([ + { + name: 'safe', + type: 'command', + description: 'Safe command description', + path: path.join(commandsDir, 'safe.md'), + }, + ]); + }); + it('ignores markdown files in shared skills root', async () => { const sharedSkillsDir = path.join(ccsDir, 'shared', 'skills'); fs.mkdirSync(sharedSkillsDir, { recursive: true });