421 lines
13 KiB
JavaScript
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
|