424 lines
12 KiB
TypeScript
424 lines
12 KiB
TypeScript
/**
|
|
* Path Validation Utilities.
|
|
*
|
|
* Provides security sandboxing for file path access to prevent
|
|
* unauthorized access to sensitive system files.
|
|
*
|
|
* Cross-platform: uses path.resolve() for consistent drive-letter
|
|
* handling on Windows (normalizeForCompare, isPathWithinRoot).
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
import { getClaudeBasePath, getHomeDir } from './pathDecoder';
|
|
|
|
/**
|
|
* Sensitive file patterns that should never be accessible.
|
|
* These are checked against the normalized absolute path.
|
|
*/
|
|
const SENSITIVE_PATTERNS: RegExp[] = [
|
|
// SSH keys and config
|
|
/[/\\]\.ssh[/\\]/i,
|
|
// AWS credentials
|
|
/[/\\]\.aws[/\\]/i,
|
|
// GCP credentials
|
|
/[/\\]\.config[/\\]gcloud[/\\]/i,
|
|
// Azure credentials
|
|
/[/\\]\.azure[/\\]/i,
|
|
// Environment files (anywhere in path)
|
|
/[/\\]\.env($|\.)/i,
|
|
// Git credentials
|
|
/[/\\]\.git-credentials$/i,
|
|
/[/\\]\.gitconfig$/i,
|
|
// NPM tokens
|
|
/[/\\]\.npmrc$/i,
|
|
// Docker credentials
|
|
/[/\\]\.docker[/\\]config\.json$/i,
|
|
// Kubernetes config
|
|
/[/\\]\.kube[/\\]config$/i,
|
|
// Password files
|
|
/[/\\]\.password/i,
|
|
/[/\\]\.secret/i,
|
|
// Private keys
|
|
/[/\\]id_rsa$/i,
|
|
/[/\\]id_ed25519$/i,
|
|
/[/\\]id_ecdsa$/i,
|
|
/[/\\][^/\\]*\.pem$/i,
|
|
/[/\\][^/\\]*\.key$/i,
|
|
// System files
|
|
/^\/etc\/passwd$/,
|
|
/^\/etc\/shadow$/,
|
|
// Credentials in filename
|
|
/credentials\.json$/i,
|
|
/secrets\.json$/i,
|
|
/tokens\.json$/i,
|
|
];
|
|
|
|
/**
|
|
* Result of path validation.
|
|
*/
|
|
export interface PathValidationResult {
|
|
valid: boolean;
|
|
error?: string;
|
|
normalizedPath?: string;
|
|
}
|
|
|
|
function normalizeForCompare(input: string, isWindows: boolean): string {
|
|
const normalized = path.resolve(path.normalize(input));
|
|
return isWindows ? normalized.toLowerCase() : normalized;
|
|
}
|
|
|
|
export function isPathWithinRoot(targetPath: string, rootPath: string): boolean {
|
|
const isWindows = process.platform === 'win32';
|
|
const target = normalizeForCompare(targetPath, isWindows);
|
|
const root = normalizeForCompare(rootPath, isWindows);
|
|
const relative = path.relative(root, target);
|
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
}
|
|
|
|
function resolveRealPathIfExists(inputPath: string): string | null {
|
|
try {
|
|
return fs.realpathSync.native(inputPath);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a path matches any sensitive file patterns.
|
|
*
|
|
* @param normalizedPath - The normalized absolute path to check
|
|
* @returns true if path matches a sensitive pattern
|
|
*/
|
|
export function matchesSensitivePattern(normalizedPath: string): boolean {
|
|
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(normalizedPath));
|
|
}
|
|
|
|
/**
|
|
* Checks if a path is within allowed directories.
|
|
*
|
|
* Allowed directories:
|
|
* - The project path itself
|
|
* - The ~/.claude directory (for session data)
|
|
*
|
|
* @param normalizedPath - The normalized absolute path to check
|
|
* @param projectPath - The project root path (can be null for global access)
|
|
* @returns true if path is within allowed directories
|
|
*/
|
|
export function isPathWithinAllowedDirectories(
|
|
normalizedPath: string,
|
|
projectPath: string | null
|
|
): boolean {
|
|
const isWindows = process.platform === 'win32';
|
|
const normalizedTarget = normalizeForCompare(normalizedPath, isWindows);
|
|
const claudeDir = getClaudeBasePath();
|
|
const normalizedClaudeDir = normalizeForCompare(claudeDir, isWindows);
|
|
|
|
// Always allow access to ~/.claude for session data
|
|
if (isPathWithinRoot(normalizedTarget, normalizedClaudeDir)) {
|
|
return true;
|
|
}
|
|
|
|
// If project path provided, allow access within project
|
|
if (projectPath) {
|
|
const normalizedProjectPath = normalizeForCompare(projectPath, isWindows);
|
|
if (isPathWithinRoot(normalizedTarget, normalizedProjectPath)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Validates a file path for safe reading.
|
|
*
|
|
* Security checks performed:
|
|
* 1. Path must be absolute
|
|
* 2. Path traversal prevention (no ..)
|
|
* 3. Must be within allowed directories (project or ~/.claude)
|
|
* 4. Must not match sensitive file patterns
|
|
*
|
|
* @param filePath - The file path to validate
|
|
* @param projectPath - The project root path (can be null for global access)
|
|
* @returns Validation result with normalized path if valid
|
|
*/
|
|
export function validateFilePath(
|
|
filePath: string,
|
|
projectPath: string | null
|
|
): PathValidationResult {
|
|
// Must be a non-empty string
|
|
if (!filePath || typeof filePath !== 'string') {
|
|
return { valid: false, error: 'Invalid file path' };
|
|
}
|
|
|
|
// Expand ~ to home directory
|
|
const expandedPath = filePath.startsWith('~')
|
|
? path.join(getHomeDir(), filePath.slice(1))
|
|
: filePath;
|
|
|
|
// Must be absolute path
|
|
const normalizedInput = path.normalize(expandedPath);
|
|
if (!path.isAbsolute(normalizedInput)) {
|
|
return { valid: false, error: 'Path must be absolute' };
|
|
}
|
|
|
|
// Normalize and resolve the path to remove traversal segments safely
|
|
const normalizedPath = path.resolve(normalizedInput);
|
|
|
|
// Check against sensitive patterns
|
|
if (matchesSensitivePattern(normalizedPath)) {
|
|
return { valid: false, error: 'Access to sensitive files is not allowed' };
|
|
}
|
|
|
|
// Check if within allowed directories
|
|
if (!isPathWithinAllowedDirectories(normalizedPath, projectPath)) {
|
|
return {
|
|
valid: false,
|
|
error: 'Path is outside allowed directories (project or Claude root)',
|
|
};
|
|
}
|
|
|
|
// If target exists, validate real path containment to prevent symlink escapes.
|
|
const realTargetPath = resolveRealPathIfExists(normalizedPath);
|
|
if (realTargetPath) {
|
|
const isWindows = process.platform === 'win32';
|
|
const normalizedRealTarget = normalizeForCompare(realTargetPath, isWindows);
|
|
if (matchesSensitivePattern(normalizedRealTarget)) {
|
|
return { valid: false, error: 'Access to sensitive files is not allowed' };
|
|
}
|
|
|
|
const realProjectPath = projectPath
|
|
? (resolveRealPathIfExists(projectPath) ?? path.resolve(path.normalize(projectPath)))
|
|
: null;
|
|
|
|
if (!isPathWithinAllowedDirectories(normalizedRealTarget, realProjectPath)) {
|
|
return {
|
|
valid: false,
|
|
error: 'Path is outside allowed directories (project or Claude root)',
|
|
};
|
|
}
|
|
}
|
|
|
|
return { valid: true, normalizedPath };
|
|
}
|
|
|
|
/**
|
|
* Validates a path for opening when it was explicitly chosen by the user
|
|
* via the system folder picker. Only checks sensitive patterns, not
|
|
* allowed-directories (project / ~/.claude).
|
|
*
|
|
* @param targetPath - The path to open
|
|
* @returns Validation result
|
|
*/
|
|
export function validateOpenPathUserSelected(targetPath: string): PathValidationResult {
|
|
if (!targetPath || typeof targetPath !== 'string') {
|
|
return { valid: false, error: 'Invalid path' };
|
|
}
|
|
|
|
const expandedPath = targetPath.startsWith('~')
|
|
? path.join(getHomeDir(), targetPath.slice(1))
|
|
: targetPath;
|
|
|
|
const normalizedPath = path.resolve(path.normalize(expandedPath));
|
|
|
|
if (!path.isAbsolute(normalizedPath)) {
|
|
return { valid: false, error: 'Path must be absolute' };
|
|
}
|
|
|
|
if (matchesSensitivePattern(normalizedPath)) {
|
|
return { valid: false, error: 'Cannot open sensitive files' };
|
|
}
|
|
|
|
const realTargetPath = resolveRealPathIfExists(normalizedPath);
|
|
if (realTargetPath) {
|
|
const isWindows = process.platform === 'win32';
|
|
const normalizedRealTarget = normalizeForCompare(realTargetPath, isWindows);
|
|
if (matchesSensitivePattern(normalizedRealTarget)) {
|
|
return { valid: false, error: 'Cannot open sensitive files' };
|
|
}
|
|
}
|
|
|
|
return { valid: true, normalizedPath };
|
|
}
|
|
|
|
/**
|
|
* Validates a path for shell:openPath operation.
|
|
* More permissive than file reading - allows opening project directories
|
|
* and Claude data directories.
|
|
*
|
|
* @param targetPath - The path to open
|
|
* @param projectPath - The project root path (can be null)
|
|
* @returns Validation result
|
|
*/
|
|
export function validateOpenPath(
|
|
targetPath: string,
|
|
projectPath: string | null
|
|
): PathValidationResult {
|
|
if (!targetPath || typeof targetPath !== 'string') {
|
|
return { valid: false, error: 'Invalid path' };
|
|
}
|
|
|
|
// Expand ~ to home directory
|
|
const expandedPath = targetPath.startsWith('~')
|
|
? path.join(getHomeDir(), targetPath.slice(1))
|
|
: targetPath;
|
|
|
|
const normalizedPath = path.resolve(path.normalize(expandedPath));
|
|
|
|
// Must be absolute after expansion
|
|
if (!path.isAbsolute(normalizedPath)) {
|
|
return { valid: false, error: 'Path must be absolute' };
|
|
}
|
|
|
|
// Check against sensitive patterns (still block sensitive files)
|
|
if (matchesSensitivePattern(normalizedPath)) {
|
|
return { valid: false, error: 'Cannot open sensitive files' };
|
|
}
|
|
|
|
// For shell:openPath, we're more permissive but still require
|
|
// the path to be within project or claude directories
|
|
if (!isPathWithinAllowedDirectories(normalizedPath, projectPath)) {
|
|
return {
|
|
valid: false,
|
|
error: 'Path is outside allowed directories',
|
|
};
|
|
}
|
|
|
|
// If target exists, validate real path containment to prevent symlink escapes.
|
|
const realTargetPath = resolveRealPathIfExists(normalizedPath);
|
|
if (realTargetPath) {
|
|
const isWindows = process.platform === 'win32';
|
|
const normalizedRealTarget = normalizeForCompare(realTargetPath, isWindows);
|
|
if (matchesSensitivePattern(normalizedRealTarget)) {
|
|
return { valid: false, error: 'Cannot open sensitive files' };
|
|
}
|
|
|
|
const realProjectPath = projectPath
|
|
? (resolveRealPathIfExists(projectPath) ?? path.resolve(path.normalize(projectPath)))
|
|
: null;
|
|
|
|
if (!isPathWithinAllowedDirectories(normalizedRealTarget, realProjectPath)) {
|
|
return {
|
|
valid: false,
|
|
error: 'Path is outside allowed directories',
|
|
};
|
|
}
|
|
}
|
|
|
|
return { valid: true, normalizedPath };
|
|
}
|
|
|
|
// =============================================================================
|
|
// Editor-specific validation utilities
|
|
// =============================================================================
|
|
|
|
const MAX_FILENAME_LENGTH = 255;
|
|
|
|
/** Characters forbidden in file/directory names. */
|
|
// eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- Intentional: validating filenames against control characters
|
|
const INVALID_FILENAME_CHARS = /[\x00-\x1f/\\:*?"<>|]/;
|
|
const WINDOWS_RESERVED_BASENAMES = new Set([
|
|
'con',
|
|
'prn',
|
|
'aux',
|
|
'nul',
|
|
'com1',
|
|
'com2',
|
|
'com3',
|
|
'com4',
|
|
'com5',
|
|
'com6',
|
|
'com7',
|
|
'com8',
|
|
'com9',
|
|
'lpt1',
|
|
'lpt2',
|
|
'lpt3',
|
|
'lpt4',
|
|
'lpt5',
|
|
'lpt6',
|
|
'lpt7',
|
|
'lpt8',
|
|
'lpt9',
|
|
]);
|
|
|
|
export function isWindowsReservedFileName(name: string): boolean {
|
|
if (typeof name !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
const normalized = name
|
|
.trim()
|
|
.replace(/[. ]+$/g, '')
|
|
.toLowerCase();
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
|
|
const stem = normalized.split('.')[0] ?? normalized;
|
|
return WINDOWS_RESERVED_BASENAMES.has(stem);
|
|
}
|
|
|
|
/**
|
|
* Validates a file or directory name for creation.
|
|
* Prevents path traversal, control chars, and OS-invalid characters.
|
|
*/
|
|
export function validateFileName(name: string): PathValidationResult {
|
|
if (!name || typeof name !== 'string') {
|
|
return { valid: false, error: 'Name is required' };
|
|
}
|
|
|
|
const trimmed = name.trim();
|
|
if (trimmed.length === 0) {
|
|
return { valid: false, error: 'Name cannot be empty' };
|
|
}
|
|
|
|
if (trimmed.length > MAX_FILENAME_LENGTH) {
|
|
return { valid: false, error: `Name exceeds ${MAX_FILENAME_LENGTH} characters` };
|
|
}
|
|
|
|
if (trimmed === '.' || trimmed === '..') {
|
|
return { valid: false, error: 'Invalid name' };
|
|
}
|
|
|
|
if (INVALID_FILENAME_CHARS.test(trimmed)) {
|
|
return { valid: false, error: 'Name contains invalid characters' };
|
|
}
|
|
|
|
if (/[. ]$/.test(name)) {
|
|
return { valid: false, error: 'Name cannot end with a space or period' };
|
|
}
|
|
|
|
if (isWindowsReservedFileName(trimmed)) {
|
|
return { valid: false, error: 'Name is reserved on Windows' };
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/** Blocked device/pseudo-filesystem path prefixes. */
|
|
const DEVICE_PATH_PREFIXES = ['/dev/', '/proc/', '/sys/'];
|
|
const WINDOWS_DEVICE_PREFIX = '\\\\.\\';
|
|
|
|
/**
|
|
* Returns true if the path points to a device or pseudo-filesystem
|
|
* (/dev/, /proc/, /sys/, \\\\.\\).
|
|
*/
|
|
export function isDevicePath(filePath: string): boolean {
|
|
const lower = filePath.toLowerCase();
|
|
if (DEVICE_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) {
|
|
return true;
|
|
}
|
|
return filePath.startsWith(WINDOWS_DEVICE_PREFIX);
|
|
}
|
|
|
|
/**
|
|
* Returns true if the path contains a `.git/` segment.
|
|
* Used to block writes to git internals.
|
|
*/
|
|
export function isGitInternalPath(filePath: string): boolean {
|
|
const normalized = filePath.replace(/\\/g, '/');
|
|
return normalized.includes('/.git/') || normalized.endsWith('/.git');
|
|
}
|