366 lines
13 KiB
TypeScript
366 lines
13 KiB
TypeScript
/**
|
|
* Tests for path validation utilities.
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import { getHomeDir, setClaudeBasePathOverride } from '../../../src/main/utils/pathDecoder';
|
|
|
|
import {
|
|
isPathWithinRoot,
|
|
isPathWithinAllowedDirectories,
|
|
isWindowsReservedFileName,
|
|
validateFileName,
|
|
validateFilePath,
|
|
validateOpenPath,
|
|
validateOpenPathUserSelected,
|
|
} from '../../../src/main/utils/pathValidation';
|
|
|
|
describe('pathValidation', () => {
|
|
const homeDir = getHomeDir();
|
|
const claudeDir = path.join(homeDir, '.claude');
|
|
const testProjectPath = path.resolve('/home/user/my-project');
|
|
|
|
beforeEach(() => {
|
|
setClaudeBasePathOverride(claudeDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
setClaudeBasePathOverride(null);
|
|
});
|
|
|
|
describe('isPathWithinAllowedDirectories', () => {
|
|
it('should allow paths within ~/.claude', () => {
|
|
expect(
|
|
isPathWithinAllowedDirectories(path.join(claudeDir, 'projects', 'test.jsonl'), null)
|
|
).toBe(true);
|
|
});
|
|
|
|
it('should allow paths within project directory', () => {
|
|
expect(
|
|
isPathWithinAllowedDirectories(
|
|
path.join(testProjectPath, 'src', 'index.ts'),
|
|
testProjectPath
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it('should reject paths outside allowed directories', () => {
|
|
expect(isPathWithinAllowedDirectories('/etc/passwd', testProjectPath)).toBe(false);
|
|
});
|
|
|
|
it('should reject home directory itself without project context', () => {
|
|
expect(isPathWithinAllowedDirectories(homeDir, null)).toBe(false);
|
|
});
|
|
|
|
it('should allow exact ~/.claude path', () => {
|
|
expect(isPathWithinAllowedDirectories(claudeDir, null)).toBe(true);
|
|
});
|
|
|
|
it('should allow exact project path', () => {
|
|
expect(isPathWithinAllowedDirectories(testProjectPath, testProjectPath)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('isPathWithinRoot', () => {
|
|
it('rejects sibling paths that only share the same prefix', () => {
|
|
const root = path.join(os.tmpdir(), 'repo');
|
|
const sibling = path.join(os.tmpdir(), 'repo2', 'file.ts');
|
|
expect(isPathWithinRoot(sibling, root)).toBe(false);
|
|
});
|
|
|
|
it('handles Windows drive casing and traversal consistently', () => {
|
|
if (process.platform !== 'win32') {
|
|
return;
|
|
}
|
|
|
|
expect(isPathWithinRoot('C:\\Repo\\File.ts', 'c:\\repo')).toBe(true);
|
|
expect(isPathWithinRoot('c:\\repo\\file.ts', 'C:\\Repo')).toBe(true);
|
|
expect(isPathWithinRoot('C:\\Repo2\\file.ts', 'C:\\Repo')).toBe(false);
|
|
expect(isPathWithinRoot('C:\\Repo\\..\\escape\\file.ts', 'C:\\Repo')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('validateFileName', () => {
|
|
it('rejects Windows reserved basenames before file creation', () => {
|
|
expect(isWindowsReservedFileName('con')).toBe(true);
|
|
expect(isWindowsReservedFileName('NUL.txt')).toBe(true);
|
|
expect(isWindowsReservedFileName('com1.json')).toBe(true);
|
|
expect(validateFileName('con').valid).toBe(false);
|
|
expect(validateFileName('NUL.txt').valid).toBe(false);
|
|
expect(validateFileName('com1.json').valid).toBe(false);
|
|
});
|
|
|
|
it('rejects trailing spaces and periods for Windows-safe names', () => {
|
|
expect(validateFileName('report.').valid).toBe(false);
|
|
expect(validateFileName('report ').valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('validateFilePath', () => {
|
|
describe('basic validation', () => {
|
|
it('should reject empty path', () => {
|
|
const result = validateFilePath('', testProjectPath);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Invalid file path');
|
|
});
|
|
|
|
it('should reject relative paths', () => {
|
|
const result = validateFilePath('src/index.ts', testProjectPath);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Path must be absolute');
|
|
});
|
|
|
|
it('should accept valid absolute paths within project', () => {
|
|
const result = validateFilePath(
|
|
path.join(testProjectPath, 'src', 'index.ts'),
|
|
testProjectPath
|
|
);
|
|
expect(result.valid).toBe(true);
|
|
expect(result.normalizedPath).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('sensitive file patterns', () => {
|
|
it('should reject ~/.ssh paths', () => {
|
|
const result = validateFilePath(path.join(homeDir, '.ssh', 'id_rsa'), testProjectPath);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Access to sensitive files is not allowed');
|
|
});
|
|
|
|
it('should reject ~/.aws paths', () => {
|
|
const result = validateFilePath(path.join(homeDir, '.aws', 'credentials'), testProjectPath);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Access to sensitive files is not allowed');
|
|
});
|
|
|
|
it('should reject .env files in project', () => {
|
|
const result = validateFilePath(path.join(testProjectPath, '.env'), testProjectPath);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Access to sensitive files is not allowed');
|
|
});
|
|
|
|
it('should reject .env.local files', () => {
|
|
const result = validateFilePath(path.join(testProjectPath, '.env.local'), testProjectPath);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Access to sensitive files is not allowed');
|
|
});
|
|
|
|
it('should reject credentials.json files', () => {
|
|
const result = validateFilePath(
|
|
path.join(testProjectPath, 'credentials.json'),
|
|
testProjectPath
|
|
);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Access to sensitive files is not allowed');
|
|
});
|
|
|
|
it('should reject .pem files', () => {
|
|
const result = validateFilePath(path.join(testProjectPath, 'server.pem'), testProjectPath);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Access to sensitive files is not allowed');
|
|
});
|
|
|
|
it('should reject .key files', () => {
|
|
const result = validateFilePath(path.join(testProjectPath, 'private.key'), testProjectPath);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Access to sensitive files is not allowed');
|
|
});
|
|
|
|
it('should reject ~/.kube/config', () => {
|
|
const result = validateFilePath(path.join(homeDir, '.kube', 'config'), testProjectPath);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Access to sensitive files is not allowed');
|
|
});
|
|
|
|
it('should reject ~/.docker/config.json', () => {
|
|
const result = validateFilePath(
|
|
path.join(homeDir, '.docker', 'config.json'),
|
|
testProjectPath
|
|
);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Access to sensitive files is not allowed');
|
|
});
|
|
|
|
it('should reject secrets.json files', () => {
|
|
const result = validateFilePath(
|
|
path.join(testProjectPath, 'config', 'secrets.json'),
|
|
testProjectPath
|
|
);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Access to sensitive files is not allowed');
|
|
});
|
|
});
|
|
|
|
describe('path traversal prevention', () => {
|
|
it('should handle normalized paths with ..', () => {
|
|
// This path resolves correctly but starts outside project
|
|
const result = validateFilePath(
|
|
path.join(testProjectPath, '..', 'other-project', 'file.ts'),
|
|
testProjectPath
|
|
);
|
|
// Should be rejected because final path is outside project
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it('should reject symlink targets that escape project directory', () => {
|
|
if (process.platform === 'win32') {
|
|
// Symlink creation may require elevated privileges on Windows CI.
|
|
return;
|
|
}
|
|
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'path-validation-'));
|
|
const projectRoot = path.join(tempRoot, 'project');
|
|
const outsideRoot = path.join(tempRoot, 'outside');
|
|
fs.mkdirSync(projectRoot, { recursive: true });
|
|
fs.mkdirSync(outsideRoot, { recursive: true });
|
|
|
|
const outsideFile = path.join(outsideRoot, 'secret.txt');
|
|
fs.writeFileSync(outsideFile, 'secret', 'utf8');
|
|
|
|
const linkedPath = path.join(projectRoot, 'linked-secret.txt');
|
|
fs.symlinkSync(outsideFile, linkedPath);
|
|
|
|
const result = validateFilePath(linkedPath, projectRoot);
|
|
expect(result.valid).toBe(false);
|
|
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe('allowed paths', () => {
|
|
it('should allow regular source files in project', () => {
|
|
const result = validateFilePath(
|
|
path.join(testProjectPath, 'src', 'components', 'App.tsx'),
|
|
testProjectPath
|
|
);
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('should allow JSON config files (non-sensitive)', () => {
|
|
const result = validateFilePath(
|
|
path.join(testProjectPath, 'package.json'),
|
|
testProjectPath
|
|
);
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('should allow JSONL files in ~/.claude', () => {
|
|
const result = validateFilePath(
|
|
path.join(claudeDir, 'projects', '-home-user-project', 'session.jsonl'),
|
|
null
|
|
);
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('tilde expansion', () => {
|
|
it('should expand ~ to home directory for paths within ~/.claude', () => {
|
|
const result = validateFilePath('~/.claude/projects/test.jsonl', null);
|
|
expect(result.valid).toBe(true);
|
|
expect(result.normalizedPath).toBe(
|
|
path.resolve(path.join(homeDir, '.claude', 'projects', 'test.jsonl'))
|
|
);
|
|
});
|
|
|
|
it('should expand ~ to home directory for project paths', () => {
|
|
const projectInHome = path.join(homeDir, 'my-project');
|
|
const result = validateFilePath('~/my-project/src/index.ts', projectInHome);
|
|
expect(result.valid).toBe(true);
|
|
expect(result.normalizedPath).toBe(
|
|
path.resolve(path.join(projectInHome, 'src', 'index.ts'))
|
|
);
|
|
});
|
|
|
|
it('should reject tilde paths to sensitive files', () => {
|
|
const result = validateFilePath('~/.ssh/id_rsa', testProjectPath);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Access to sensitive files is not allowed');
|
|
});
|
|
|
|
it('should reject tilde paths outside allowed directories', () => {
|
|
const result = validateFilePath('~/random-dir/file.txt', testProjectPath);
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('validateOpenPath', () => {
|
|
it('should expand tilde in paths', () => {
|
|
const result = validateOpenPath('~/.claude', null);
|
|
expect(result.valid).toBe(true);
|
|
expect(result.normalizedPath).toBe(path.resolve(claudeDir));
|
|
});
|
|
|
|
it('should reject sensitive files', () => {
|
|
const result = validateOpenPath(path.join(homeDir, '.ssh', 'id_rsa'), testProjectPath);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Cannot open sensitive files');
|
|
});
|
|
|
|
it('should reject empty path', () => {
|
|
const result = validateOpenPath('', testProjectPath);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Invalid path');
|
|
});
|
|
|
|
it('should allow project directory', () => {
|
|
const result = validateOpenPath(testProjectPath, testProjectPath);
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('should allow ~/.claude directory', () => {
|
|
const result = validateOpenPath(claudeDir, null);
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('should reject paths outside allowed directories', () => {
|
|
const result = validateOpenPath('/etc', testProjectPath);
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it('should reject symlink paths that escape project directory', () => {
|
|
if (process.platform === 'win32') {
|
|
return;
|
|
}
|
|
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'open-path-validation-'));
|
|
const projectRoot = path.join(tempRoot, 'project');
|
|
const outsideRoot = path.join(tempRoot, 'outside');
|
|
fs.mkdirSync(projectRoot, { recursive: true });
|
|
fs.mkdirSync(outsideRoot, { recursive: true });
|
|
|
|
const linkedDir = path.join(projectRoot, 'linked-outside');
|
|
fs.symlinkSync(outsideRoot, linkedDir);
|
|
|
|
const result = validateOpenPath(linkedDir, projectRoot);
|
|
expect(result.valid).toBe(false);
|
|
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe('validateOpenPathUserSelected', () => {
|
|
it('should allow path outside project when chosen by user', () => {
|
|
const outsidePath = path.join(homeDir, 'some-other-project');
|
|
const result = validateOpenPathUserSelected(outsidePath);
|
|
expect(result.valid).toBe(true);
|
|
expect(result.normalizedPath).toBe(path.resolve(outsidePath));
|
|
});
|
|
|
|
it('should reject sensitive paths', () => {
|
|
const result = validateOpenPathUserSelected(path.join(homeDir, '.ssh', 'id_rsa'));
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Cannot open sensitive files');
|
|
});
|
|
|
|
it('should reject empty path', () => {
|
|
const result = validateOpenPathUserSelected('');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.error).toBe('Invalid path');
|
|
});
|
|
});
|
|
});
|