UNPKG

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
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); }