mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-05 02:13:35 +00:00
* fix: reject case-variant symlinked file paths * fix: validate requested case-variant symlink paths
195 lines
6.3 KiB
TypeScript
195 lines
6.3 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { validateFilePath } from '../../../src/web-server/routes/route-helpers';
|
|
|
|
describe('validateFilePath', () => {
|
|
let tempDir: string;
|
|
let originalCcsHome: string | undefined;
|
|
let originalClaudeConfigDir: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'route-helpers-test-'));
|
|
originalCcsHome = process.env.CCS_HOME;
|
|
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
|
|
process.env.CCS_HOME = tempDir;
|
|
process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude-custom');
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalCcsHome === undefined) {
|
|
delete process.env.CCS_HOME;
|
|
} else {
|
|
process.env.CCS_HOME = originalCcsHome;
|
|
}
|
|
|
|
if (originalClaudeConfigDir === undefined) {
|
|
delete process.env.CLAUDE_CONFIG_DIR;
|
|
} else {
|
|
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
|
}
|
|
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('allows files within ~/.ccs tree', () => {
|
|
const filePath = path.join(tempDir, '.ccs', 'config.yaml');
|
|
const result = validateFilePath(filePath);
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.readonly).toBe(false);
|
|
});
|
|
|
|
test('rejects writes to the macOS bar launch descriptor', () => {
|
|
const filePath = path.join(tempDir, '.ccs', 'bar', 'launch.json');
|
|
const result = validateFilePath(filePath);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.readonly).toBe(false);
|
|
});
|
|
|
|
test('rejects case variants of the macOS bar launch descriptor', () => {
|
|
const filePath = path.join(tempDir, '.ccs', 'BAR', 'LAUNCH.JSON');
|
|
const result = validateFilePath(filePath);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.readonly).toBe(false);
|
|
});
|
|
|
|
test('still allows other writes inside the bar directory', () => {
|
|
const filePath = path.join(tempDir, '.ccs', 'bar', 'serve.log');
|
|
const result = validateFilePath(filePath);
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.readonly).toBe(false);
|
|
});
|
|
|
|
test('rejects sibling paths that only share ~/.ccs prefix', () => {
|
|
const bypassPath = path.join(tempDir, '.ccs-evil', 'config.yaml');
|
|
const result = validateFilePath(bypassPath);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.readonly).toBe(false);
|
|
});
|
|
|
|
test('allows readonly access to resolved Claude settings path', () => {
|
|
const filePath = path.join(tempDir, '.claude-custom', 'settings.json');
|
|
const result = validateFilePath(filePath);
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.readonly).toBe(true);
|
|
});
|
|
|
|
test('rejects symlinked paths inside ~/.ccs tree', () => {
|
|
if (process.platform === 'win32') {
|
|
return;
|
|
}
|
|
|
|
const ccsDir = path.join(tempDir, '.ccs');
|
|
const outsideDir = path.join(tempDir, 'outside');
|
|
const linkedDir = path.join(ccsDir, 'linked');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
fs.mkdirSync(outsideDir, { recursive: true });
|
|
fs.symlinkSync(outsideDir, linkedDir, 'dir');
|
|
|
|
const result = validateFilePath(path.join(linkedDir, 'config.yaml'));
|
|
expect(result.valid).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', () => {
|
|
if (process.platform === 'win32') {
|
|
return;
|
|
}
|
|
|
|
const claudeDir = path.join(tempDir, '.claude-custom');
|
|
const targetFile = path.join(tempDir, 'target-settings.json');
|
|
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
fs.writeFileSync(targetFile, '{}');
|
|
fs.symlinkSync(targetFile, settingsPath, 'file');
|
|
|
|
const result = validateFilePath(settingsPath);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.readonly).toBe(false);
|
|
});
|
|
});
|