filetree-pro
Version:
A powerful file tree generator for VS Code and Cursor. Generate beautiful file trees in multiple formats with smart exclusions and custom configurations.
326 lines (287 loc) • 9.97 kB
text/typescript
/**
* Security utilities for path validation, pattern validation, and file size checks.
* Prevents path traversal, ReDoS attacks, and resource exhaustion.
*
* @module securityUtils
* @author FileTree Pro Team
* @since 0.2.0
*/
import * as path from 'path';
/**
* Maximum file size for processing (50MB)
* Prevents memory exhaustion from processing very large files
*/
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
/**
* Maximum pattern length to prevent ReDoS attacks
* Patterns longer than this are likely malicious or poorly constructed
*/
export const MAX_PATTERN_LENGTH = 1000;
/**
* Maximum path depth to prevent directory traversal attacks
* Unix systems typically limit paths to 4096 chars, this is a safe subset
*/
export const MAX_PATH_DEPTH = 100;
/**
* Validation result with success status and optional error message
*/
export interface ValidationResult {
/** Whether the validation passed */
readonly valid: boolean;
/** Human-readable error message if validation failed */
readonly error?: string;
}
/**
* Validates a file system path for security issues.
* Checks for:
* - Path traversal attempts (../, ..\, etc.)
* - Absolute path escapes
* - Null bytes and special characters
* - Excessive path depth
*
* Time Complexity: O(n) where n is path length
* Space Complexity: O(n) for normalized path
*
* @param targetPath - The file system path to validate
* @param basePath - Optional base path to validate against
* @returns Validation result with success status and optional error
*
* @example
* ```typescript
* const result = validatePath('/home/user/project/file.ts', '/home/user/project');
* if (!result.valid) {
* console.error(result.error);
* }
* ```
*/
export function validatePath(targetPath: string, basePath?: string): ValidationResult {
// Check for null or empty path
if (!targetPath || targetPath.trim().length === 0) {
return { valid: false, error: 'Path cannot be empty' };
}
// Check for null bytes (security risk in some file systems)
if (targetPath.includes('\0')) {
return { valid: false, error: 'Path contains null bytes' };
}
// Check for excessive length (possible DoS)
if (targetPath.length > MAX_PATH_DEPTH * 255) {
// Typical max filename is 255 chars
return { valid: false, error: 'Path exceeds maximum length' };
}
// Check for path traversal attempts BEFORE normalization
if (targetPath.includes('..')) {
return { valid: false, error: 'Path contains directory traversal sequence (..)' };
}
// Normalize the path to resolve any ., or // sequences
const normalizedPath = path.normalize(targetPath);
// Check for path segments after normalization
const pathSegments = normalizedPath.split(path.sep);
// Check depth to prevent extremely deep directory structures
if (pathSegments.length > MAX_PATH_DEPTH) {
return { valid: false, error: `Path depth exceeds maximum (${MAX_PATH_DEPTH})` };
}
// If basePath is provided, ensure targetPath is within basePath
if (basePath) {
const normalizedBase = path.normalize(basePath);
const resolvedPath = path.resolve(normalizedBase, normalizedPath);
// Ensure resolved path starts with base path (prevents escapes)
if (!resolvedPath.startsWith(normalizedBase)) {
return { valid: false, error: 'Path attempts to escape base directory' };
}
}
return { valid: true };
}
/**
* Validates a glob pattern for ReDoS (Regular Expression Denial of Service) risks.
* Checks for:
* - Excessive pattern length
* - Nested quantifiers (e.g., (a+)+)
* - Catastrophic backtracking patterns
*
* Time Complexity: O(n) where n is pattern length
* Space Complexity: O(1)
*
* @param pattern - The glob pattern to validate
* @returns Validation result with success status and optional error
*
* @example
* ```typescript
* const result = validatePattern('*.ts');
* if (!result.valid) {
* console.error(result.error);
* }
* ```
*/
export function validatePattern(pattern: string): ValidationResult {
// Check for null or empty pattern
if (!pattern || pattern.trim().length === 0) {
return { valid: false, error: 'Pattern cannot be empty' };
}
// Check pattern length to prevent extremely complex patterns
if (pattern.length > MAX_PATTERN_LENGTH) {
return { valid: false, error: `Pattern exceeds maximum length (${MAX_PATTERN_LENGTH})` };
}
// Check for nested quantifiers that can cause ReDoS
// Patterns like (a+)+, (a*)+, (a{1,5})+, etc.
const nestedQuantifierRegex = /\([^)]*[*+{][^)]*\)[*+{]/g;
if (nestedQuantifierRegex.test(pattern)) {
return { valid: false, error: 'Pattern contains nested quantifiers (ReDoS risk)' };
}
// Check for alternation with overlapping patterns (catastrophic backtracking)
// Patterns like (a|a)+ or (a|ab)+
const alternationRegex = /\([^)|]+\|[^)]+\)[*+{]/g;
const matches = pattern.match(alternationRegex);
if (matches) {
for (const match of matches) {
// Extract alternatives
const alternatives = match
.slice(1, -2) // Remove ( and )+
.split('|');
// Check if any alternative is a prefix of another
for (let i = 0; i < alternatives.length; i++) {
for (let j = i + 1; j < alternatives.length; j++) {
if (
alternatives[i].startsWith(alternatives[j]) ||
alternatives[j].startsWith(alternatives[i])
) {
return {
valid: false,
error: 'Pattern contains overlapping alternatives (ReDoS risk)',
};
}
}
}
}
}
// Check for excessive use of wildcards
const wildcardCount = (pattern.match(/\*/g) || []).length;
if (wildcardCount > 10) {
return { valid: false, error: 'Pattern contains too many wildcards' };
}
return { valid: true };
}
/**
* Validates a file size against maximum allowed size.
* Prevents memory exhaustion from processing very large files.
*
* Time Complexity: O(1)
* Space Complexity: O(1)
*
* @param fileSize - The file size in bytes
* @param maxSize - Optional custom maximum size (defaults to MAX_FILE_SIZE)
* @returns Validation result with success status and optional error
*
* @example
* ```typescript
* const result = validateFileSize(1024 * 1024); // 1MB
* if (!result.valid) {
* console.error(result.error);
* }
* ```
*/
export function validateFileSize(
fileSize: number,
maxSize: number = MAX_FILE_SIZE
): ValidationResult {
// Check for negative size
if (fileSize < 0) {
return { valid: false, error: 'File size cannot be negative' };
}
// Check for NaN or Infinity
if (!Number.isFinite(fileSize)) {
return { valid: false, error: 'File size must be a finite number' };
}
// Check against maximum
if (fileSize > maxSize) {
const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(2);
const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
return {
valid: false,
error: `File size (${fileSizeMB}MB) exceeds maximum (${maxSizeMB}MB)`,
};
}
return { valid: true };
}
/**
* Sanitizes a filename by removing potentially dangerous characters.
* Useful for generating safe output filenames.
*
* Time Complexity: O(n) where n is filename length
* Space Complexity: O(n) for sanitized string
*
* @param filename - The filename to sanitize
* @returns Sanitized filename safe for file system operations
*
* @example
* ```typescript
* const safe = sanitizeFilename('my<file>name.txt'); // Returns: 'my_file_name.txt'
* ```
*/
export function sanitizeFilename(filename: string): string {
// Remove null bytes
let sanitized = filename.replace(/\0/g, '');
// Replace dangerous characters with underscores
// Dangerous: < > : " / \ | ? * and control characters
sanitized = sanitized.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_');
// Trim whitespace and dots from start/end (Windows requirement)
sanitized = sanitized.trim().replace(/^\.+|\.+$/g, '');
// Ensure filename is not empty or only underscores after sanitization
if (sanitized.length === 0 || /^_+$/.test(sanitized)) {
sanitized = 'untitled';
}
// Limit length to safe maximum (255 is common filesystem limit)
if (sanitized.length > 255) {
const extension = path.extname(sanitized);
const basename = path.basename(sanitized, extension);
sanitized = basename.slice(0, 255 - extension.length) + extension;
}
return sanitized;
}
/**
* Validates an array of exclusion patterns for security issues.
* Checks each pattern and returns the first invalid result.
*
* Time Complexity: O(n*m) where n is number of patterns, m is average pattern length
* Space Complexity: O(1)
*
* @param patterns - Array of glob patterns to validate
* @returns Validation result with success status and optional error
*
* @example
* ```typescript
* const result = validateExclusionPatterns(['*.log', 'node_modules', 'dist']);
* if (!result.valid) {
* console.error(result.error);
* }
* ```
*/
export function validateExclusionPatterns(patterns: string[]): ValidationResult {
// Check for null or empty array
if (!Array.isArray(patterns)) {
return { valid: false, error: 'Patterns must be an array' };
}
// Check for excessive number of patterns (potential DoS)
if (patterns.length > 1000) {
return { valid: false, error: 'Too many exclusion patterns (max: 1000)' };
}
// Validate each pattern
for (let i = 0; i < patterns.length; i++) {
const pattern = patterns[i];
// Check pattern type
if (typeof pattern !== 'string') {
return {
valid: false,
error: `Pattern at index ${i} is not a string (type: ${typeof pattern})`,
};
}
// Validate pattern for ReDoS
const result = validatePattern(pattern);
if (!result.valid) {
return {
valid: false,
error: `Pattern at index ${i} ('${pattern}'): ${result.error}`,
};
}
}
return { valid: true };
}