fix(api): validate analyze path from raw segments

Reject explicit '..' path segments in /api/analyze and share validation logic with unit tests to prevent behavior drift.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
wwenrr 2026-04-09 00:15:15 +09:00
parent 51aba4ac98
commit d2622ed67e
2 changed files with 30 additions and 52 deletions

View file

@ -105,6 +105,19 @@ export const isAllowedOrigin = (origin: string | undefined): boolean => {
return false;
};
export const validateAnalyzePath = (repoLocalPath: string): string | null => {
if (!path.isAbsolute(repoLocalPath)) {
return '"path" must be an absolute path';
}
const pathSegments = repoLocalPath.split(/[\\/]+/);
if (pathSegments.includes('..')) {
return '"path" must not contain traversal sequences';
}
return null;
};
const buildGraph = async (
includeContent = false,
): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => {
@ -864,30 +877,11 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
return;
}
// Path validation: require absolute path, reject traversal (e.g. /tmp/../etc/passwd)
// while still accepting common absolute-path variants with trailing separators.
// Path validation: require absolute path and reject explicit traversal segments.
if (repoLocalPath) {
if (!path.isAbsolute(repoLocalPath)) {
res.status(400).json({ error: '"path" must be an absolute path' });
return;
}
const normalizedInput = path.normalize(repoLocalPath);
const resolvedInput = path.resolve(repoLocalPath);
// normalize() may keep a trailing separator (e.g. /home/user/project/)
// while resolve() typically strips it. Compare without trailing separators
// so valid absolute paths are accepted consistently.
const stripTrailingSeparator = (p: string): string => {
if (p.length <= 1) return p;
// Preserve root paths, including Windows drive roots like C:\\
if (p === path.parse(p).root || /^[A-Za-z]:[\\/]?$/.test(p)) return p;
return p.replace(/[\\/]+$/, '');
};
if (
stripTrailingSeparator(normalizedInput) !== stripTrailingSeparator(resolvedInput)
) {
res.status(400).json({ error: '"path" must not contain traversal sequences' });
const pathError = validateAnalyzePath(repoLocalPath);
if (pathError) {
res.status(400).json({ error: pathError });
return;
}
}

View file

@ -1,43 +1,27 @@
import path from 'path';
import { describe, expect, it } from 'vitest';
const hasTraversalSequence = (repoLocalPath: string): boolean => {
const normalizedInput = path.normalize(repoLocalPath);
const resolvedInput = path.resolve(repoLocalPath);
const stripTrailingSeparator = (p: string): string => {
if (p.length <= 1) return p;
return p.replace(/[\\/]+$/, '');
};
return stripTrailingSeparator(normalizedInput) !== stripTrailingSeparator(resolvedInput);
};
import { validateAnalyzePath } from '../../src/server/api.js';
describe('analyze path validation', () => {
it('accepts absolute paths with trailing separator', () => {
expect(hasTraversalSequence('/home/user/project/')).toBe(false);
expect(hasTraversalSequence('/home/user/project//')).toBe(false);
expect(validateAnalyzePath('/home/user/project/')).toBeNull();
expect(validateAnalyzePath('/home/user/project//')).toBeNull();
});
it('accepts normalized absolute paths', () => {
expect(hasTraversalSequence('/home/user/project')).toBe(false);
expect(validateAnalyzePath('/home/user/project')).toBeNull();
});
it('rejects traversal sequences', () => {
expect(hasTraversalSequence('/tmp/project/../other')).toBe(true);
it('rejects traversal segments from raw input', () => {
expect(validateAnalyzePath('/tmp/project/../other')).toBe(
'"path" must not contain traversal sequences',
);
});
it('rejects relative paths', () => {
expect(validateAnalyzePath('tmp/project')).toBe('"path" must be an absolute path');
});
it('preserves Windows drive root semantics when stripping separators', () => {
const normalizedRoot = path.win32.normalize('C:\\');
const resolvedRoot = path.win32.resolve('C:\\');
const stripTrailingSeparator = (p: string): string => {
if (p.length <= 1) return p;
if (p === path.win32.parse(p).root || /^[A-Za-z]:[\\/]?$/.test(p)) return p;
return p.replace(/[\\/]+$/, '');
};
expect(stripTrailingSeparator(normalizedRoot)).toBe('C:\\');
expect(stripTrailingSeparator(resolvedRoot)).toBe('C:\\');
it('accepts Windows drive roots', () => {
expect(validateAnalyzePath('C:\\')).toBeNull();
});
});