claude-flow-novice
Version:
Claude Flow Novice - Advanced orchestration platform for multi-agent AI workflows with CFN Loop architecture Includes Local RuVector Accelerator and all CFN skills for complete functionality.
438 lines (437 loc) • 17.1 kB
JavaScript
/**
* Path Validator - Security Utility for Safe File Operations
*
* Provides robust path sanitization and validation to prevent path traversal attacks (CVSS 7.0+).
* Enforces strict rules on file access with protection against encoding attacks:
*
* CRITICAL FIXES (CVSS 7.0+):
* - Iterative URL decoding prevents double-encoding bypasses (%252e%252e%252f → ../)
* - Unicode normalization (NFC) prevents overlong UTF-8 bypasses (%c0%ae → .)
* - Null byte detection prevents null injection attacks
* - All decoding performed BEFORE path normalization to prevent layered attacks
* - Encoding attack detection with security logging
*
* Base Security Controls:
* - Path normalization to resolve ".." and "." sequences
* - Validation that resolved paths stay within allowed directories
* - Detection and rejection of symlinks
* - Rejection of absolute paths outside allowed directories
* - Prevention of home directory access ("~")
*
* @module path-validator
* @version 2.0.0 (SECURITY CRITICAL)
*/ import * as path from 'path';
import * as fs from 'fs';
import { StandardError } from './errors';
/**
* Path validation error - thrown when a path violates security constraints
*/ export class PathValidationError extends StandardError {
constructor(message, context){
super('PATH_VALIDATION_ERROR', message, context);
this.name = 'PathValidationError';
}
}
/**
* Safely decode a path with protection against encoding attacks
*
* Performs iterative URL decoding and Unicode normalization to prevent:
* - Double encoding bypass (e.g., %252e%252e%252f → %2e%2e%2f → ../)
* - Overlong UTF-8 encoding (e.g., %c0%ae%c0%ae/ → ../)
* - Mixed encoding attacks
*
* @param inputPath - The potentially encoded path
* @returns Decoded path and attack detection info
* @throws PathValidationError if encoding attack detected
*
* @example
* const { decoded, encoding } = decodePathSafely('%252e%252e%252f');
* // Detects double-encoding attempt
*/ function decodePathSafely(inputPath) {
let decoded = inputPath;
let previous = '';
let iterations = 0;
const MAX_ITERATIONS = 5;
const originalInput = inputPath;
let invalidEncodingDetected = false;
// Iteratively decode URL-encoded characters until stable
// This prevents bypass attacks using multiple encoding layers
while(decoded !== previous && iterations < MAX_ITERATIONS){
previous = decoded;
try {
decoded = decodeURIComponent(decoded);
} catch (error) {
// Invalid URL encoding can be legitimate (e.g., file100%, %PATH%)
// These are literal percent signs in filenames, not attacks
// Only flag as attack if it's malformed UTF-8 (overlong encoding)
const errorMessage = error.message || '';
// Malformed UTF-8 sequences like %c0%ae are attacks
if (decoded.match(/%[cC][0-9a-fA-F]/) || decoded.match(/%[eE][0-9a-fA-F]/)) {
invalidEncodingDetected = true;
break;
}
break;
}
iterations++;
}
// Check if we hit max iterations (indicates potential encoding attack)
if (iterations >= MAX_ITERATIONS && decoded !== previous) {
throw new PathValidationError('Path validation failed: excessive encoding layers detected', {
originalInput,
decodedOutput: decoded,
iterations,
reason: 'ENCODING_ATTACK_DETECTED'
});
}
// Invalid encoding like malformed UTF-8 is itself an attack indicator
if (invalidEncodingDetected) {
throw new PathValidationError('Path validation failed: invalid encoding detected (possible encoding attack)', {
originalInput,
decodedOutput: decoded,
iterations,
reason: 'INVALID_ENCODING_DETECTED'
});
}
// Detect double+ encoding attacks (3+ iterations = double-encoded or more)
// Single-level URL encoding requires 2 iterations: decode + stability check
// Example: subdir%2ffile.txt → subdir/file.txt → stable (2 iterations, legitimate)
// Example: %252e%252e%252f → %2e%2e%2f → ../ (3 iterations, attack)
const encodingAttackDetected = iterations > 2;
// Unicode normalization to handle overlong UTF-8 sequences
// e.g., %c0%ae (%c0%ae = UTF-8 overlong encoding for ".")
let normalized = decoded;
try {
normalized = decoded.normalize('NFC');
} catch (error) {
// Some paths may not be valid Unicode, continue with non-normalized version
}
// Check for null bytes (another common encoding attack vector)
if (normalized.includes('\0')) {
throw new PathValidationError('Path validation failed: null byte injection detected', {
originalInput,
decodedOutput: normalized,
reason: 'NULL_BYTE_INJECTION'
});
}
return {
decoded: normalized,
encoding: {
detected: encodingAttackDetected,
type: encodingAttackDetected ? 'double_encoding' : undefined,
originalInput,
decodedOutput: normalized,
iterationsRequired: iterations
}
};
}
/**
* Validate a file path to prevent directory traversal attacks
*
* Security checks performed (in order):
* 1. CRITICAL: Iteratively decode URL encoding to handle %252e%252e%252f and similar bypasses
* 2. CRITICAL: Normalize Unicode (NFC) to handle overlong UTF-8 like %c0%ae%c0%ae/
* 3. CRITICAL: Detect null bytes and excessive encoding layers
* 4. Check for home directory expansion ("~") on DECODED path
* 5. Normalize path to resolve ".." and "."
* 6. Verify all suspicious patterns are eliminated
* 7. Check if path is within base directory
* 8. Reject symlinks to prevent symlink attacks
* 9. Log any encoding attacks detected for security monitoring
*
* @param filePath - The file path to validate (may be encoded)
* @param baseDirectory - The base directory that file must reside within
* @returns PathValidationResult with validation details
* @throws PathValidationError if path validation fails or encoding attack detected
*
* @example
* const result = validatePath('docs/FEATURE.md', './.claude/skills');
* if (!result.valid) {
* throw result; // Safe to throw, contains all context
* }
* // Use result.resolvedPath
*
* @security
* Designed to prevent:
* - Double-encoding bypasses: %252e%252e%252f
* - Overlong UTF-8: %c0%ae%c0%ae/
* - Mixed encoding: URL + Unicode combinations
* - Null byte injection: file.txt%00.jpg
* - Traditional path traversal: ../../../etc/passwd
*/ export function validatePath(filePath, baseDirectory) {
// SECURITY FIX: Decode all encoding layers first before any path normalization
// This prevents double-encoding bypasses like %252e%252e%252f
const { decoded: decodedPath, encoding: pathEncoding } = decodePathSafely(filePath);
const { decoded: decodedBase } = decodePathSafely(baseDirectory);
// Log encoding attacks for security monitoring
if (pathEncoding.detected) {
// In production, this should trigger security alerts
console.warn('Security: Encoding attack detected in path input', {
originalInput: pathEncoding.originalInput,
decodedOutput: pathEncoding.decodedOutput,
iterationsRequired: pathEncoding.iterationsRequired
});
}
// Check for home directory expansion attempts on DECODED path
if (decodedPath.startsWith('~') || decodedPath.includes('/~') || decodedPath.includes('\\~')) {
throw new PathValidationError('Path validation failed: home directory access denied', {
filePath,
decodedPath,
baseDirectory,
reason: 'HOME_DIRECTORY_ACCESS'
});
}
// Check for home directory expansion attempts in baseDirectory
if (decodedBase.startsWith('~')) {
throw new PathValidationError('Base directory validation failed: home directory access denied', {
baseDirectory,
decodedBase,
reason: 'BASE_HOME_DIRECTORY_ACCESS'
});
}
// Normalize the base directory first
const normalizedBase = path.normalize(decodedBase);
const resolvedBase = path.resolve(normalizedBase);
// Normalize and resolve the file path relative to base
// NOW normalized on already-decoded path to prevent encoding bypasses
const normalizedPath = path.normalize(decodedPath);
// Check if path contains suspicious patterns after normalization
if (normalizedPath.includes('..') || normalizedPath === '.' || normalizedPath.includes('/./')) {
throw new PathValidationError('Path validation failed: path contains directory traversal patterns', {
filePath,
decodedPath,
normalizedPath,
baseDirectory,
reason: 'TRAVERSAL_PATTERN_DETECTED'
});
}
// Resolve the path relative to base directory
const resolvedPath = path.resolve(resolvedBase, normalizedPath);
// Check if resolved path is within base directory
const isWithinBase = isPathWithinBase(resolvedPath, resolvedBase);
if (!isWithinBase) {
throw new PathValidationError('Path validation failed: resolved path is outside allowed directory', {
filePath,
resolvedPath,
baseDirectory: resolvedBase,
reason: 'PATH_OUTSIDE_BASE'
});
}
// Check for symlinks (prevents symlink attacks)
let isSymlink = false;
try {
const stats = fs.lstatSync(resolvedPath);
isSymlink = stats.isSymbolicLink();
if (isSymlink) {
throw new PathValidationError('Path validation failed: symbolic links are not allowed', {
filePath,
resolvedPath,
reason: 'SYMLINK_NOT_ALLOWED'
});
}
} catch (error) {
// File doesn't exist yet (expected for validation before creation)
// or it's a symlink that was rejected above
if (error instanceof PathValidationError) {
throw error;
}
// Other errors (permission denied, etc.) are not path validation failures
// The file validation happens later
}
return {
valid: true,
resolvedPath,
normalizedPath,
isWithinBase: true,
isSymlink: false
};
}
/**
* Check if a path is within a base directory
*
* Uses path resolution and string comparison to ensure the resolved path
* is actually within the base directory (not just sharing a prefix).
*
* @param filePath - The path to check
* @param baseDirectory - The base directory
* @returns True if filePath is within baseDirectory
*
* @example
* isPathWithinBase('/home/user/project/src/file.ts', '/home/user/project') // true
* isPathWithinBase('/home/user/project-evil/file.ts', '/home/user/project') // false
*/ export function isPathWithinBase(filePath, baseDirectory) {
// Ensure both paths are normalized and absolute
const normalizedFile = path.normalize(path.resolve(filePath));
const normalizedBase = path.normalize(path.resolve(baseDirectory));
// Exact match
if (normalizedFile === normalizedBase) {
return true;
}
// Check if file is within base (use path.relative to ensure it's not going up)
const relative = path.relative(normalizedBase, normalizedFile);
// If relative path starts with "..", it's outside the base directory
if (relative.startsWith('..')) {
return false;
}
// If relative path is absolute, it's outside the base directory
if (path.isAbsolute(relative)) {
return false;
}
return true;
}
/**
* Validate multiple paths within the same base directory
*
* Efficiently validates multiple paths, returning results for each.
*
* @param filePaths - Array of file paths to validate
* @param baseDirectory - The base directory that all files must reside within
* @returns Map of file path to validation result
*
* @example
* const results = validatePaths(['docs/SKILL.md', 'src/index.ts'], './.claude/skills');
* results.forEach((result, filePath) => {
* if (!result.valid) {
* console.error(`Invalid path: ${filePath}`, result.reason);
* }
* });
*/ export function validatePaths(filePaths, baseDirectory) {
const results = new Map();
for (const filePath of filePaths){
try {
const result = validatePath(filePath, baseDirectory);
results.set(filePath, result);
} catch (error) {
if (error instanceof PathValidationError) {
results.set(filePath, {
valid: false,
resolvedPath: '',
normalizedPath: '',
isWithinBase: false,
isSymlink: false,
reason: error.context?.reason
});
} else {
throw error;
}
}
}
return results;
}
/**
* Get the safe path for a file (or throw if validation fails)
*
* Convenience function that validates and returns the resolved path,
* or throws an error if validation fails.
*
* @param filePath - The file path to validate
* @param baseDirectory - The base directory that file must reside within
* @returns The resolved, validated absolute path
* @throws PathValidationError if path validation fails
*
* @example
* const safePath = getSafePath('docs/SKILL.md', './.claude/skills');
* fs.readFileSync(safePath); // Safe to use
*/ export function getSafePath(filePath, baseDirectory) {
const result = validatePath(filePath, baseDirectory);
return result.resolvedPath;
}
/**
* Check if a path is considered safe for operations
*
* This is a non-throwing version of validatePath for conditional logic.
*
* @param filePath - The file path to check
* @param baseDirectory - The base directory
* @returns True if path is safe, false otherwise
*
* @example
* if (isPathSafe(userInput, './.claude/skills')) {
* // Process the file
* } else {
* // Reject the request
* }
*/ export function isPathSafe(filePath, baseDirectory) {
try {
validatePath(filePath, baseDirectory);
return true;
} catch {
return false;
}
}
/**
* Get validation error details for a path (if invalid)
*
* Useful for logging and diagnostics.
*
* @param filePath - The file path to validate
* @param baseDirectory - The base directory
* @returns Error with details, or undefined if path is valid
*
* @example
* const error = getPathValidationError('../../etc/passwd', './.claude/skills');
* if (error) {
* logger.error(error.message, error.context);
* }
*/ export function getPathValidationError(filePath, baseDirectory) {
try {
validatePath(filePath, baseDirectory);
return undefined;
} catch (error) {
if (error instanceof PathValidationError) {
return error;
}
throw error;
}
}
/**
* List allowed files within a base directory (safely)
*
* Recursively lists all files within base directory, validating
* each path to ensure it's within bounds.
*
* @param baseDirectory - The base directory to scan
* @param options - Options for listing (maxDepth, filter)
* @returns Array of validated, safe paths relative to baseDirectory
*
* @example
* const files = safeListDirectory('./.claude/skills');
* files.forEach(file => {
* // All paths are guaranteed safe
* console.log(file);
* });
*/ export function safeListDirectory(baseDirectory, options) {
const safeFiles = [];
const maxDepth = options?.maxDepth ?? Infinity;
const filter = options?.filter ?? (()=>true);
function walkDirectory(dir, currentDepth = 0) {
if (currentDepth > maxDepth) {
return;
}
try {
const entries = fs.readdirSync(dir, {
withFileTypes: true
});
for (const entry of entries){
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(baseDirectory, fullPath);
// Validate the path is still within base
if (!isPathWithinBase(fullPath, baseDirectory)) {
continue;
}
// Apply filter
if (!filter(relativePath)) {
continue;
}
safeFiles.push(relativePath);
// Recursively walk directories
if (entry.isDirectory() && !entry.isSymbolicLink()) {
walkDirectory(fullPath, currentDepth + 1);
}
}
} catch (error) {
// Silently skip directories we can't read (permission denied, etc.)
}
}
walkDirectory(baseDirectory);
return safeFiles;
}
//# sourceMappingURL=path-validator.js.map