tasq/node_modules/@claude-flow/security/dist/path-validator.js

421 lines
13 KiB
JavaScript

/**
* Path Validator - HIGH-2 Remediation
*
* Fixes path traversal vulnerabilities by:
* - Validating all file paths against allowed prefixes
* - Using path.resolve() for canonicalization
* - Blocking traversal patterns (../, etc.)
* - Enforcing path length limits
*
* Security Properties:
* - Path canonicalization
* - Prefix validation
* - Symlink resolution (optional)
* - Traversal pattern detection
*
* @module v3/security/path-validator
*/
import * as path from 'path';
import * as fs from 'fs/promises';
export class PathValidatorError extends Error {
code;
path;
constructor(message, code, path) {
super(message);
this.code = code;
this.path = path;
this.name = 'PathValidatorError';
}
}
/**
* Dangerous path patterns that indicate traversal attempts.
*/
const TRAVERSAL_PATTERNS = [
/\.\.\//, // ../
/\.\.\\/, // ..\
/\.\./, // .. anywhere
/%2e%2e/i, // URL-encoded ..
/%252e%252e/i, // Double URL-encoded ..
/\.%2e/i, // Mixed encoding
/%2e\./i, // Mixed encoding
/\0/, // Null byte
/%00/, // URL-encoded null
];
/**
* Default blocked file extensions (sensitive files).
*/
const DEFAULT_BLOCKED_EXTENSIONS = [
'.env',
'.pem',
'.key',
'.crt',
'.pfx',
'.p12',
'.jks',
'.keystore',
'.secret',
'.credentials',
];
/**
* Default blocked file names (sensitive files).
*/
const DEFAULT_BLOCKED_NAMES = [
'id_rsa',
'id_dsa',
'id_ecdsa',
'id_ed25519',
'.htpasswd',
'.htaccess',
'shadow',
'passwd',
'authorized_keys',
'known_hosts',
'.git',
'.gitconfig',
'.npmrc',
'.docker',
];
/**
* Path validator that prevents traversal attacks.
*
* This class validates file paths to ensure they stay within
* allowed directories and don't access sensitive files.
*
* @example
* ```typescript
* const validator = new PathValidator({
* allowedPrefixes: ['/workspaces/project']
* });
*
* const result = await validator.validate('/workspaces/project/src/file.ts');
* if (result.isValid) {
* // Safe to use result.resolvedPath
* }
* ```
*/
export class PathValidator {
config;
resolvedPrefixes;
constructor(config) {
this.config = {
allowedPrefixes: config.allowedPrefixes,
blockedExtensions: config.blockedExtensions ?? DEFAULT_BLOCKED_EXTENSIONS,
blockedNames: config.blockedNames ?? DEFAULT_BLOCKED_NAMES,
maxPathLength: config.maxPathLength ?? 4096,
resolveSymlinks: config.resolveSymlinks ?? true,
allowNonExistent: config.allowNonExistent ?? true,
allowHidden: config.allowHidden ?? false,
};
if (this.config.allowedPrefixes.length === 0) {
throw new PathValidatorError('At least one allowed prefix must be specified', 'EMPTY_PREFIXES');
}
// Pre-resolve all prefixes
this.resolvedPrefixes = this.config.allowedPrefixes.map(p => path.resolve(p));
}
/**
* Validates a path against security rules.
*
* @param inputPath - The path to validate
* @returns Validation result with resolved path
*/
async validate(inputPath) {
const errors = [];
// Check for empty path
if (!inputPath || inputPath.trim() === '') {
return {
isValid: false,
resolvedPath: '',
relativePath: '',
matchedPrefix: '',
errors: ['Path is empty'],
};
}
// Check path length
if (inputPath.length > this.config.maxPathLength) {
return {
isValid: false,
resolvedPath: '',
relativePath: '',
matchedPrefix: '',
errors: [`Path exceeds maximum length of ${this.config.maxPathLength}`],
};
}
// Check for traversal patterns
for (const pattern of TRAVERSAL_PATTERNS) {
if (pattern.test(inputPath)) {
return {
isValid: false,
resolvedPath: '',
relativePath: '',
matchedPrefix: '',
errors: ['Path traversal pattern detected'],
};
}
}
// Resolve the path
let resolvedPath;
try {
resolvedPath = path.resolve(inputPath);
// Optionally resolve symlinks
if (this.config.resolveSymlinks) {
try {
resolvedPath = await fs.realpath(resolvedPath);
}
catch (error) {
// Path doesn't exist yet - use resolved path
if (error.code !== 'ENOENT' || !this.config.allowNonExistent) {
if (error.code === 'ENOENT') {
errors.push('Path does not exist');
}
else {
errors.push(`Failed to resolve path: ${error.message}`);
}
}
}
}
}
catch (error) {
return {
isValid: false,
resolvedPath: '',
relativePath: '',
matchedPrefix: '',
errors: [`Invalid path: ${error.message}`],
};
}
// Check against allowed prefixes
let matchedPrefix = '';
let relativePath = '';
let prefixMatched = false;
for (const prefix of this.resolvedPrefixes) {
if (resolvedPath === prefix || resolvedPath.startsWith(prefix + path.sep)) {
prefixMatched = true;
matchedPrefix = prefix;
relativePath = resolvedPath.slice(prefix.length);
if (relativePath.startsWith(path.sep)) {
relativePath = relativePath.slice(1);
}
break;
}
}
if (!prefixMatched) {
return {
isValid: false,
resolvedPath,
relativePath: '',
matchedPrefix: '',
errors: ['Path is outside allowed directories'],
};
}
// Check for hidden files
const pathParts = resolvedPath.split(path.sep);
if (!this.config.allowHidden) {
for (const part of pathParts) {
if (part.startsWith('.') && part !== '.' && part !== '..') {
errors.push('Hidden files/directories are not allowed');
break;
}
}
}
// Check blocked file names
const basename = path.basename(resolvedPath);
if (this.config.blockedNames.includes(basename)) {
errors.push(`File name "${basename}" is blocked`);
}
// Check blocked extensions
const ext = path.extname(resolvedPath).toLowerCase();
if (this.config.blockedExtensions.includes(ext)) {
errors.push(`File extension "${ext}" is blocked`);
}
// Also check for double extensions (e.g., .tar.gz, .config.json)
const fullname = basename.toLowerCase();
for (const blockedExt of this.config.blockedExtensions) {
if (fullname.endsWith(blockedExt)) {
errors.push(`File extension "${blockedExt}" is blocked`);
break;
}
}
return {
isValid: errors.length === 0,
resolvedPath,
relativePath,
matchedPrefix,
errors,
};
}
/**
* Validates and returns resolved path, throwing on failure.
*
* @param inputPath - The path to validate
* @returns Resolved path if valid
* @throws PathValidatorError if validation fails
*/
async validateOrThrow(inputPath) {
const result = await this.validate(inputPath);
if (!result.isValid) {
throw new PathValidatorError(result.errors.join('; '), 'VALIDATION_FAILED', inputPath);
}
return result.resolvedPath;
}
/**
* Synchronous validation (without symlink resolution).
*
* @param inputPath - The path to validate
* @returns Validation result
*/
validateSync(inputPath) {
const errors = [];
if (!inputPath || inputPath.trim() === '') {
return {
isValid: false,
resolvedPath: '',
relativePath: '',
matchedPrefix: '',
errors: ['Path is empty'],
};
}
if (inputPath.length > this.config.maxPathLength) {
return {
isValid: false,
resolvedPath: '',
relativePath: '',
matchedPrefix: '',
errors: [`Path exceeds maximum length of ${this.config.maxPathLength}`],
};
}
for (const pattern of TRAVERSAL_PATTERNS) {
if (pattern.test(inputPath)) {
return {
isValid: false,
resolvedPath: '',
relativePath: '',
matchedPrefix: '',
errors: ['Path traversal pattern detected'],
};
}
}
const resolvedPath = path.resolve(inputPath);
let matchedPrefix = '';
let relativePath = '';
let prefixMatched = false;
for (const prefix of this.resolvedPrefixes) {
if (resolvedPath === prefix || resolvedPath.startsWith(prefix + path.sep)) {
prefixMatched = true;
matchedPrefix = prefix;
relativePath = resolvedPath.slice(prefix.length);
if (relativePath.startsWith(path.sep)) {
relativePath = relativePath.slice(1);
}
break;
}
}
if (!prefixMatched) {
return {
isValid: false,
resolvedPath,
relativePath: '',
matchedPrefix: '',
errors: ['Path is outside allowed directories'],
};
}
const pathParts = resolvedPath.split(path.sep);
if (!this.config.allowHidden) {
for (const part of pathParts) {
if (part.startsWith('.') && part !== '.' && part !== '..') {
errors.push('Hidden files/directories are not allowed');
break;
}
}
}
const basename = path.basename(resolvedPath);
if (this.config.blockedNames.includes(basename)) {
errors.push(`File name "${basename}" is blocked`);
}
const ext = path.extname(resolvedPath).toLowerCase();
if (this.config.blockedExtensions.includes(ext)) {
errors.push(`File extension "${ext}" is blocked`);
}
return {
isValid: errors.length === 0,
resolvedPath,
relativePath,
matchedPrefix,
errors,
};
}
/**
* Securely joins path segments within allowed directories.
*
* @param prefix - Base directory (must be in allowedPrefixes)
* @param segments - Path segments to join
* @returns Validated resolved path
*/
async securePath(prefix, ...segments) {
// Join the segments
const joined = path.join(prefix, ...segments);
// Validate the result
return this.validateOrThrow(joined);
}
/**
* Adds a prefix to the allowed list at runtime.
*
* @param prefix - Prefix to add
*/
addPrefix(prefix) {
const resolved = path.resolve(prefix);
if (!this.resolvedPrefixes.includes(resolved)) {
this.config.allowedPrefixes.push(prefix);
this.resolvedPrefixes.push(resolved);
}
}
/**
* Returns the current allowed prefixes.
*/
getAllowedPrefixes() {
return [...this.resolvedPrefixes];
}
/**
* Checks if a path is within allowed prefixes (quick check).
*/
isWithinAllowed(inputPath) {
try {
const resolved = path.resolve(inputPath);
return this.resolvedPrefixes.some(prefix => resolved === prefix || resolved.startsWith(prefix + path.sep));
}
catch {
return false;
}
}
}
/**
* Factory function to create a path validator for a project directory.
*
* @param projectRoot - Root directory of the project
* @returns Configured PathValidator
*/
export function createProjectPathValidator(projectRoot) {
const srcDir = path.join(projectRoot, 'src');
const testDir = path.join(projectRoot, 'tests');
const docsDir = path.join(projectRoot, 'docs');
return new PathValidator({
allowedPrefixes: [srcDir, testDir, docsDir],
allowHidden: false,
});
}
/**
* Factory function to create a path validator for the entire project.
*
* @param projectRoot - Root directory of the project
* @returns Configured PathValidator
*/
export function createFullProjectPathValidator(projectRoot) {
return new PathValidator({
allowedPrefixes: [projectRoot],
allowHidden: true, // Allow .gitignore, etc.
blockedNames: [
...DEFAULT_BLOCKED_NAMES,
'node_modules', // Block access to node_modules
],
});
}
//# sourceMappingURL=path-validator.js.map