mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +00:00
fix: reject case-variant symlinked file paths (#1629)
* fix: reject case-variant symlinked file paths * fix: validate requested case-variant symlink paths
This commit is contained in:
1 parent
53fe7e1e8d
commit
1ee7cac555
2 files changed
+117
-5
No files matched your search
@@ -399,14 +399,50 @@ function isSymlinkPath(filePath: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasSymlinkSegment(basePath: string, targetPath: string): boolean {
|
function getRequestedBasePath(basePath: string, targetPath: string): string {
|
||||||
const relative = path.relative(basePath, targetPath);
|
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)) {
|
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentPath = basePath;
|
const requestedBasePath = getRequestedBasePath(basePath, targetPath);
|
||||||
const segments = relative.split(path.sep).filter(Boolean);
|
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) {
|
for (const segment of segments) {
|
||||||
currentPath = path.join(currentPath, segment);
|
currentPath = path.join(currentPath, segment);
|
||||||
if (isSymlinkPath(currentPath)) {
|
if (isSymlinkPath(currentPath)) {
|
||||||
@@ -432,7 +468,7 @@ export function validateFilePath(filePath: string): {
|
|||||||
|
|
||||||
// Check if path is within ~/.ccs/
|
// Check if path is within ~/.ccs/
|
||||||
if (isPathWithin(ccsDir, normalizedPath)) {
|
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' };
|
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,82 @@ describe('validateFilePath', () => {
|
|||||||
expect(result.readonly).toBe(false);
|
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', () => {
|
test('rejects symlinked Claude settings path', () => {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in new issue
Block a user