mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 04:17:54 +00:00
fix(shared): harden markdown traversal and parsing
This commit is contained in:
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user