diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 7e5ace2d..2fedac56 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -399,14 +399,50 @@ function isSymlinkPath(filePath: string): boolean { } } -function hasSymlinkSegment(basePath: string, targetPath: string): boolean { - const relative = path.relative(basePath, targetPath); +function getRequestedBasePath(basePath: string, targetPath: string): string { + const comparisonBasePath = normalizePathForComparison(basePath); + const comparisonTargetPath = normalizePathForComparison(targetPath); + const comparisonBaseWithSeparator = comparisonBasePath.endsWith(path.sep) + ? comparisonBasePath + : `${comparisonBasePath}${path.sep}`; + + if ( + comparisonTargetPath === comparisonBasePath || + comparisonTargetPath.startsWith(comparisonBaseWithSeparator) + ) { + return targetPath.slice(0, basePath.length); + } + + return basePath; +} + +function hasSymlinkSegment( + basePath: string, + targetPath: string, + comparisonBasePath: string, + comparisonTargetPath: string +): boolean { + const relative = path.relative(comparisonBasePath, comparisonTargetPath); if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { return false; } - let currentPath = basePath; - const segments = relative.split(path.sep).filter(Boolean); + const requestedBasePath = getRequestedBasePath(basePath, targetPath); + if (requestedBasePath !== basePath && isSymlinkPath(requestedBasePath)) { + return true; + } + + const requestedRelative = path.relative(requestedBasePath, targetPath); + if ( + requestedRelative === '' || + requestedRelative.startsWith('..') || + path.isAbsolute(requestedRelative) + ) { + return false; + } + + let currentPath = requestedBasePath; + const segments = requestedRelative.split(path.sep).filter(Boolean); for (const segment of segments) { currentPath = path.join(currentPath, segment); if (isSymlinkPath(currentPath)) { @@ -432,7 +468,7 @@ export function validateFilePath(filePath: string): { // Check if path is within ~/.ccs/ if (isPathWithin(ccsDir, normalizedPath)) { - if (hasSymlinkSegment(resolvedCcsDir, resolvedPath)) { + if (hasSymlinkSegment(resolvedCcsDir, resolvedPath, ccsDir, normalizedPath)) { return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; } diff --git a/tests/unit/web-server/route-helpers.test.ts b/tests/unit/web-server/route-helpers.test.ts index 581d1ee9..1cd4cbac 100644 --- a/tests/unit/web-server/route-helpers.test.ts +++ b/tests/unit/web-server/route-helpers.test.ts @@ -99,6 +99,82 @@ describe('validateFilePath', () => { expect(result.readonly).toBe(false); }); + test('rejects macOS case-variant paths through symlinked segments', () => { + if (process.platform === 'win32') { + return; + } + + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + try { + const ccsDir = path.join(tempDir, '.ccs'); + const caseVariantCcsDir = path.join(tempDir, '.CCS'); + const sharedDir = path.join(ccsDir, 'shared'); + const outsideCommandsDir = path.join(tempDir, '.claude', 'commands'); + const linkedCommandsDir = path.join(sharedDir, 'commands'); + + fs.mkdirSync(sharedDir, { recursive: true }); + fs.mkdirSync(outsideCommandsDir, { recursive: true }); + try { + fs.symlinkSync(ccsDir, caseVariantCcsDir, 'dir'); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code !== 'EEXIST') { + throw error; + } + } + fs.symlinkSync(outsideCommandsDir, linkedCommandsDir, 'dir'); + + const result = validateFilePath( + path.join(caseVariantCcsDir, 'shared', 'commands', 'pwned.md') + ); + + expect(result.valid).toBe(false); + expect(result.readonly).toBe(false); + } finally { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + } + }); + + test('rejects macOS case-variant CCS directory symlinks', () => { + if (process.platform === 'win32') { + return; + } + + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + try { + const ccsDir = path.join(tempDir, '.ccs'); + const caseVariantCcsDir = path.join(tempDir, '.CCS'); + const outsideDir = path.join(tempDir, 'outside'); + + fs.mkdirSync(ccsDir, { recursive: true }); + fs.mkdirSync(outsideDir, { recursive: true }); + try { + fs.symlinkSync(outsideDir, caseVariantCcsDir, 'dir'); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'EEXIST') { + return; + } + throw error; + } + + const result = validateFilePath(path.join(caseVariantCcsDir, 'pwned.md')); + + expect(result.valid).toBe(false); + expect(result.readonly).toBe(false); + } finally { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + } + }); + test('rejects symlinked Claude settings path', () => { if (process.platform === 'win32') { return;