diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 45aa5d9f1..91cd8ad71 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -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; } } diff --git a/gitnexus/test/unit/analyze-path-validation.test.ts b/gitnexus/test/unit/analyze-path-validation.test.ts index 744853007..167543f44 100644 --- a/gitnexus/test/unit/analyze-path-validation.test.ts +++ b/gitnexus/test/unit/analyze-path-validation.test.ts @@ -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(); }); });