vibe-coder-mcp
Version:
Production-ready MCP server with complete agent integration, multi-transport support, and comprehensive development automation tools for AI-assisted workflows.
254 lines (253 loc) • 10.1 kB
JavaScript
import path from 'path';
import fs from 'fs/promises';
import logger from '../../../logger.js';
const DEFAULT_CONFIG = {
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
}
};
export class PathSecurityValidator {
config;
isTestMode;
constructor(config) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.isTestMode = process.env.NODE_ENV === 'test';
this.config.allowedBasePaths = this.config.allowedBasePaths.map(basePath => path.resolve(basePath));
if (this.isTestMode) {
logger.debug('PathSecurityValidator running in test mode - security validation relaxed');
}
}
async validatePath(inputPath) {
const warnings = [];
try {
if (!inputPath || typeof inputPath !== 'string') {
return {
isValid: false,
error: 'Path must be a non-empty string'
};
}
if (this.isTestMode) {
const testResult = await this.validateTestModePath(inputPath);
if (!testResult.isValid) {
return testResult;
}
if (!this.config.strictMode) {
const resolvedPath = path.resolve(inputPath);
return {
isValid: true,
sanitizedPath: resolvedPath,
warnings: testResult.warnings || ['Test mode: enhanced security validation active']
};
}
}
if (inputPath.length > this.config.maxPathLength) {
return {
isValid: false,
error: `Path exceeds maximum length of ${this.config.maxPathLength} characters`
};
}
if (inputPath.includes('\0')) {
return {
isValid: false,
error: 'Path contains null bytes'
};
}
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'
};
}
const resolvedPath = path.resolve(inputPath);
if (this.containsPathTraversal(inputPath)) {
return {
isValid: false,
error: 'Path contains directory traversal sequences'
};
}
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'
};
}
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');
}
}
if (stats && stats.isSymbolicLink() && !this.config.allowSymlinks) {
return {
isValid: false,
error: 'Symbolic links are not allowed'
};
}
if (stats && !stats.isFile()) {
return {
isValid: false,
error: 'Path must point to a file, not a directory'
};
}
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)}`
};
}
}
containsPathTraversal(inputPath) {
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()));
}
async validatePaths(inputPaths) {
return Promise.all(inputPaths.map(path => this.validatePath(path)));
}
updateConfig(newConfig) {
this.config = { ...this.config, ...newConfig };
this.config.allowedBasePaths = this.config.allowedBasePaths.map(basePath => path.resolve(basePath));
}
async validateTestModePath(inputPath) {
const warnings = [];
if (inputPath.includes('\0')) {
return {
isValid: false,
error: 'Path contains null bytes - blocked even in test mode'
};
}
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'
};
}
const criticalMaliciousPatterns = [
new RegExp(String.fromCharCode(0), 'g'),
/\$\(/g,
/`/g
];
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`
};
}
}
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`
};
}
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;
}
}
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']
};
}
getConfig() {
return { ...this.config };
}
getSecurityMetrics() {
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
};
}
}
export const defaultPathValidator = new PathSecurityValidator();
export async function validateSecurePath(inputPath) {
return defaultPathValidator.validatePath(inputPath);
}