path-security-validator.ts•12.8 kB
/**
* Path Security Validator
*
* Provides secure file path validation and sanitization to prevent
* path injection attacks, directory traversal, and other security vulnerabilities.
*/
import path from 'path';
import fs from 'fs/promises';
import logger from '../../../logger.js';
/**
* Security validation result
*/
export interface PathValidationResult {
/** Whether the path is valid and safe */
isValid: boolean;
/** Sanitized absolute path */
sanitizedPath?: string;
/** Error message if validation failed */
error?: string;
/** Security warnings */
warnings?: string[];
}
/**
* Path security configuration
*/
export interface PathSecurityConfig {
/** Allowed base directories */
allowedBasePaths: string[];
/** Allowed file extensions */
allowedExtensions: string[];
/** Maximum path length */
maxPathLength: number;
/** Whether to allow symlinks */
allowSymlinks: boolean;
/** Whether to perform strict validation */
strictMode: boolean;
/** Test mode specific settings */
testMode?: {
/** Additional allowed test directories */
allowedTestPaths?: string[];
/** Whether to log all test mode accesses */
enableTestLogging?: boolean;
/** Maximum path length multiplier for test mode */
pathLengthMultiplier?: number;
/** Whether to allow relaxed extension validation in test mode */
relaxedExtensions?: boolean;
};
}
/**
* Default security configuration
*/
const DEFAULT_CONFIG: PathSecurityConfig = {
allowedBasePaths: [
process.env.VIBE_CODER_OUTPUT_DIR || path.join(process.cwd(), 'VibeCoderOutput'),
process.env.VIBE_TASK_MANAGER_READ_DIR || process.cwd()
],
allowedExtensions: ['.md', '.json', '.txt', '.yaml', '.yml'],
maxPathLength: 1000,
allowSymlinks: false,
strictMode: true,
testMode: {
allowedTestPaths: [
'/tmp',
path.join(process.cwd(), '__tests__'),
path.join(process.cwd(), 'test'),
path.join(process.cwd(), 'tests'),
path.join(process.cwd(), 'spec')
],
enableTestLogging: true,
pathLengthMultiplier: 2,
relaxedExtensions: true
}
};
/**
* Path Security Validator class
*/
export class PathSecurityValidator {
private config: PathSecurityConfig;
private isTestMode: boolean;
constructor(config?: Partial<PathSecurityConfig>) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.isTestMode = process.env.NODE_ENV === 'test';
// Normalize allowed base paths
this.config.allowedBasePaths = this.config.allowedBasePaths.map(basePath =>
path.resolve(basePath)
);
if (this.isTestMode) {
logger.debug('PathSecurityValidator running in test mode - security validation relaxed');
}
}
/**
* Validate and sanitize a file path
*/
async validatePath(inputPath: string): Promise<PathValidationResult> {
const warnings: string[] = [];
try {
// Basic input validation
if (!inputPath || typeof inputPath !== 'string') {
return {
isValid: false,
error: 'Path must be a non-empty string'
};
}
// Enhanced test mode validation - secure but permissive for testing
if (this.isTestMode) {
const testResult = await this.validateTestModePath(inputPath);
if (!testResult.isValid) {
return testResult;
}
// If test mode validation passes and strictMode is disabled, return early with relaxed validation
if (!this.config.strictMode) {
const resolvedPath = path.resolve(inputPath);
return {
isValid: true,
sanitizedPath: resolvedPath,
warnings: testResult.warnings || ['Test mode: enhanced security validation active']
};
}
// Continue with normal validation if strictMode is enabled (for testing)
}
// Check path length
if (inputPath.length > this.config.maxPathLength) {
return {
isValid: false,
error: `Path exceeds maximum length of ${this.config.maxPathLength} characters`
};
}
// Check for null bytes (common in path injection attacks)
if (inputPath.includes('\0')) {
return {
isValid: false,
error: 'Path contains null bytes'
};
}
// Check for dangerous characters
const dangerousChars = /[<>"|?*]/;
const controlChars = new RegExp('[' + String.fromCharCode(0) + '-' + String.fromCharCode(31) + ']');
if (dangerousChars.test(inputPath) || controlChars.test(inputPath)) {
return {
isValid: false,
error: 'Path contains dangerous characters'
};
}
// Resolve and normalize the path
const resolvedPath = path.resolve(inputPath);
// Check for path traversal attempts
if (this.containsPathTraversal(inputPath)) {
return {
isValid: false,
error: 'Path contains directory traversal sequences'
};
}
// Validate against allowed base paths
const isWithinAllowedPath = this.config.allowedBasePaths.some(basePath => {
const relativePath = path.relative(basePath, resolvedPath);
return !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
});
if (!isWithinAllowedPath) {
return {
isValid: false,
error: 'Path is outside allowed directories'
};
}
// Check if file exists and get stats first
let stats;
try {
stats = await fs.lstat(resolvedPath);
} catch {
if (this.config.strictMode) {
return {
isValid: false,
error: 'File does not exist or is not accessible'
};
} else {
warnings.push('File does not exist but path validation passed');
}
}
// Check for symlinks if not allowed
if (stats && stats.isSymbolicLink() && !this.config.allowSymlinks) {
return {
isValid: false,
error: 'Symbolic links are not allowed'
};
}
// Ensure it's a file (not a directory) - check this before extension validation
if (stats && !stats.isFile()) {
return {
isValid: false,
error: 'Path must point to a file, not a directory'
};
}
// Check file extension (only if we have allowed extensions configured)
const extension = path.extname(resolvedPath).toLowerCase();
if (this.config.allowedExtensions.length > 0 &&
!this.config.allowedExtensions.includes(extension)) {
return {
isValid: false,
error: `File extension '${extension}' is not allowed`
};
}
return {
isValid: true,
sanitizedPath: resolvedPath,
warnings: warnings.length > 0 ? warnings : undefined
};
} catch (error) {
logger.error({ err: error, inputPath }, 'Path validation failed with exception');
return {
isValid: false,
error: `Path validation error: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* Check if path contains traversal sequences
*/
private containsPathTraversal(inputPath: string): boolean {
// Check for various path traversal patterns
const traversalPatterns = [
'../',
'..\\',
'/..',
'\\..',
'%2e%2e%2f',
'%2e%2e%5c',
'..%2f',
'..%5c',
'%252e%252e%252f',
'%252e%252e%255c'
];
const lowerPath = inputPath.toLowerCase();
return traversalPatterns.some(pattern =>
lowerPath.includes(pattern.toLowerCase())
);
}
/**
* Validate multiple paths
*/
async validatePaths(inputPaths: string[]): Promise<PathValidationResult[]> {
return Promise.all(inputPaths.map(path => this.validatePath(path)));
}
/**
* Update configuration
*/
updateConfig(newConfig: Partial<PathSecurityConfig>): void {
this.config = { ...this.config, ...newConfig };
// Re-normalize allowed base paths
this.config.allowedBasePaths = this.config.allowedBasePaths.map(basePath =>
path.resolve(basePath)
);
}
/**
* Enhanced test mode path validation
* Maintains critical security checks while allowing test operations
*/
private async validateTestModePath(inputPath: string): Promise<PathValidationResult> {
const warnings: string[] = [];
// Critical security checks that apply even in test mode - only the most dangerous patterns
// 1. Check for null bytes (critical security risk)
if (inputPath.includes('\0')) {
return {
isValid: false,
error: 'Path contains null bytes - blocked even in test mode'
};
}
// 2. Check for extremely dangerous control characters (only most critical ones)
const criticalDangerousChars = new RegExp('[' +
String.fromCharCode(0) + '-' + String.fromCharCode(8) +
String.fromCharCode(11) + String.fromCharCode(12) +
String.fromCharCode(14) + '-' + String.fromCharCode(31) + ']');
if (criticalDangerousChars.test(inputPath)) {
return {
isValid: false,
error: 'Path contains control characters - blocked even in test mode'
};
}
// 3. Only block the most critical malicious patterns in test mode
// Let normal validation handle directory traversal and other patterns
const criticalMaliciousPatterns = [
new RegExp(String.fromCharCode(0), 'g'), // Null bytes
/\$\(/g, // Command substitution
/`/g // Backticks
];
for (const pattern of criticalMaliciousPatterns) {
if (pattern.test(inputPath)) {
return {
isValid: false,
error: `Path contains potentially malicious pattern: ${pattern.source} - blocked even in test mode`
};
}
}
// 4. Check path length (even in test mode, prevent extremely long paths)
const pathLengthMultiplier = this.config.testMode?.pathLengthMultiplier ?? 2;
const testModeMaxLength = this.config.maxPathLength * pathLengthMultiplier;
if (inputPath.length > testModeMaxLength) {
return {
isValid: false,
error: `Path exceeds test mode maximum length of ${testModeMaxLength} characters`
};
}
// 5. Test-specific allowed patterns and warnings
const testPatterns = [
{ pattern: /\/tmp\/.*test/i, warning: 'Test mode: allowing temporary test directory' },
{ pattern: /test-output/i, warning: 'Test mode: allowing test output directory' },
{ pattern: /\.test\./i, warning: 'Test mode: allowing test file pattern' },
{ pattern: /mock.*data/i, warning: 'Test mode: allowing mock data access' },
{ pattern: /fixtures/i, warning: 'Test mode: allowing test fixtures access' }
];
for (const { pattern, warning } of testPatterns) {
if (pattern.test(inputPath)) {
warnings.push(warning);
break; // Only add one test pattern warning
}
}
// 6. Log test mode access for security monitoring (if enabled)
if (this.config.testMode?.enableTestLogging !== false) {
logger.debug({
inputPath,
testMode: true,
securityLevel: 'enhanced',
warnings,
configuredTestPaths: this.config.testMode?.allowedTestPaths?.length || 0
}, 'Test mode path validation with enhanced security');
}
return {
isValid: true,
warnings: warnings.length > 0 ? warnings : ['Test mode: enhanced security validation passed']
};
}
/**
* Get current configuration
*/
getConfig(): PathSecurityConfig {
return { ...this.config };
}
/**
* Get security validation metrics for monitoring
*/
getSecurityMetrics(): {
isTestMode: boolean;
securityLevel: string;
allowedBasePaths: number;
allowedTestPaths: number;
maxPathLength: number;
testModeMaxPathLength: number;
} {
const pathLengthMultiplier = this.config.testMode?.pathLengthMultiplier ?? 2;
return {
isTestMode: this.isTestMode,
securityLevel: this.isTestMode ? 'enhanced-test' : 'strict-production',
allowedBasePaths: this.config.allowedBasePaths.length,
allowedTestPaths: this.config.testMode?.allowedTestPaths?.length ?? 0,
maxPathLength: this.config.maxPathLength,
testModeMaxPathLength: this.config.maxPathLength * pathLengthMultiplier
};
}
}
/**
* Default instance for convenience
*/
export const defaultPathValidator = new PathSecurityValidator();
/**
* Convenience function for quick path validation
*/
export async function validateSecurePath(inputPath: string): Promise<PathValidationResult> {
return defaultPathValidator.validatePath(inputPath);
}