fix(shared): harden markdown traversal and parsing

This commit is contained in:
Tam Nhu Tran
2026-02-26 14:57:57 +07:00
parent 346fa5fcda
commit 46b4f9c1cf
2 changed files with 57 additions and 7 deletions
+18 -7
View File
@@ -13,6 +13,10 @@ import { getClaudeConfigDir } from '../utils/claude-config-path';
export const sharedRoutes = Router(); 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 { interface SharedItem {
name: string; name: string;
description: string; description: string;
@@ -222,16 +226,19 @@ interface MarkdownFileEntry {
} }
function collectMarkdownFiles(sharedDir: string, allowedRoots: Set<string>): MarkdownFileEntry[] { function collectMarkdownFiles(sharedDir: string, allowedRoots: Set<string>): MarkdownFileEntry[] {
const directoriesToVisit = [sharedDir]; const directoriesToVisit: Array<{ path: string; depth: number }> = [
{ path: sharedDir, depth: 0 },
];
const visitedDirectories = new Set<string>(); const visitedDirectories = new Set<string>();
const markdownFiles: MarkdownFileEntry[] = []; const markdownFiles: MarkdownFileEntry[] = [];
while (directoriesToVisit.length > 0) { while (directoriesToVisit.length > 0) {
const currentDir = directoriesToVisit.pop(); const current = directoriesToVisit.pop();
if (!currentDir) { if (!current) {
continue; continue;
} }
const currentDir = current.path;
const resolvedCurrentDir = safeRealPath(currentDir); const resolvedCurrentDir = safeRealPath(currentDir);
if (!resolvedCurrentDir || !isPathWithinAny(resolvedCurrentDir, allowedRoots)) { if (!resolvedCurrentDir || !isPathWithinAny(resolvedCurrentDir, allowedRoots)) {
continue; continue;
@@ -265,7 +272,9 @@ function collectMarkdownFiles(sharedDir: string, allowedRoots: Set<string>): Mar
} }
if (stats.isDirectory()) { if (stats.isDirectory()) {
directoriesToVisit.push(entryPath); if (current.depth < MAX_DIRECTORY_TRAVERSAL_DEPTH) {
directoriesToVisit.push({ path: entryPath, depth: current.depth + 1 });
}
continue; continue;
} }
@@ -324,12 +333,11 @@ function stripFrontmatter(content: string): string {
} }
function trimDescription(description: string): string { function trimDescription(description: string): string {
const maxLength = 140; if (description.length <= MAX_DESCRIPTION_LENGTH) {
if (description.length <= maxLength) {
return description; return description;
} }
return `${description.slice(0, maxLength - 3).trimEnd()}...`; return `${description.slice(0, MAX_DESCRIPTION_LENGTH - 3).trimEnd()}...`;
} }
function readFirstMarkdownDescription( function readFirstMarkdownDescription(
@@ -357,6 +365,9 @@ function readMarkdownDescription(markdownPath: string, allowedRoots: Set<string>
if (!stats.isFile()) { if (!stats.isFile()) {
return null; return null;
} }
if (stats.size > MAX_MARKDOWN_FILE_BYTES) {
return null;
}
const content = fs.readFileSync(resolvedMarkdownPath, 'utf8'); const content = fs.readFileSync(resolvedMarkdownPath, 'utf8');
return extractDescription(content); return extractDescription(content);
} catch { } catch {
@@ -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 () => { it('ignores markdown files in shared skills root', async () => {
const sharedSkillsDir = path.join(ccsDir, 'shared', 'skills'); const sharedSkillsDir = path.join(ccsDir, 'shared', 'skills');
fs.mkdirSync(sharedSkillsDir, { recursive: true }); fs.mkdirSync(sharedSkillsDir, { recursive: true });