quality-mcp
Version:
An MCP server that analyzes to your codebase, with plugin support for DCD and Simian. 🏍️ "The only Zen you find on the tops of mountains is the Zen you bring up there."
389 lines (329 loc) • 11.4 kB
JavaScript
/**
* Security utilities for input validation and path sanitization
* Protects against path traversal attacks and validates user inputs
*/
import { resolve, isAbsolute, relative } from 'path';
import { existsSync, statSync } from 'fs';
import { createLogger } from './logger.js';
const logger = createLogger('security');
/**
* Security validation errors
*/
export class SecurityValidationError extends Error {
constructor(message, type = 'VALIDATION_ERROR') {
super(message);
this.name = 'SecurityValidationError';
this.type = type;
}
}
/**
* Configuration for path validation
*/
const DEFAULT_SECURITY_CONFIG = {
// Maximum path length to prevent DoS attacks
maxPathLength: 1000,
// Allowed file extensions (empty array means all extensions allowed)
allowedExtensions: [],
// Blocked file extensions for security
blockedExtensions: ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.scr', '.vbs'],
// Maximum file size for analysis (in bytes) - 100MB default
maxFileSize: 100 * 1024 * 1024,
// Allow absolute paths (should be false in production)
allowAbsolutePaths: true,
// Base directories where analysis is allowed (empty array means any directory)
allowedBasePaths: [],
// Blocked path patterns (regex strings)
blockedPatterns: [
'/etc/',
'/proc/',
'/sys/',
'/dev/',
'/root/',
'\\\\Windows\\\\',
'\\\\Program Files\\\\',
'\\\\System32\\\\',
],
};
/**
* Validate and sanitize a file or directory path
* @param {string} inputPath - The path to validate
* @param {Object} options - Validation options
* @returns {string} Sanitized path
* @throws {SecurityValidationError} If path is invalid or dangerous
*/
export function validatePath(inputPath, options = {}) {
const config = { ...DEFAULT_SECURITY_CONFIG, ...options };
// Sanitize malformed config values
if (config.maxPathLength <= 0) {
config.maxPathLength = DEFAULT_SECURITY_CONFIG.maxPathLength;
}
if (!Array.isArray(config.blockedPatterns)) {
config.blockedPatterns = DEFAULT_SECURITY_CONFIG.blockedPatterns;
}
// Basic input validation
if (typeof inputPath !== 'string') {
throw new SecurityValidationError('Path must be a string', 'INVALID_TYPE');
}
if (inputPath.length === 0) {
throw new SecurityValidationError('Path cannot be empty', 'EMPTY_PATH');
}
if (inputPath.length > config.maxPathLength) {
throw new SecurityValidationError(
`Path too long (max ${config.maxPathLength} characters)`,
'PATH_TOO_LONG'
);
}
// Check for null bytes (potential injection)
if (inputPath.includes('\0')) {
throw new SecurityValidationError('Path contains null bytes', 'NULL_BYTE_INJECTION');
}
// Normalize and resolve the path
let normalizedPath;
try {
// For relative paths, resolve relative to current working directory
if (isAbsolute(inputPath)) {
normalizedPath = resolve(inputPath);
} else {
normalizedPath = resolve(process.cwd(), inputPath);
}
} catch (error) {
throw new SecurityValidationError(`Invalid path format: ${error.message}`, 'INVALID_FORMAT');
}
// Check for path traversal attempts
const relativePath = relative(process.cwd(), normalizedPath);
if (relativePath.startsWith('..') && !config.allowAbsolutePaths) {
throw new SecurityValidationError('Path traversal attempt detected', 'PATH_TRAVERSAL');
}
// Validate absolute paths
if (isAbsolute(inputPath) && !config.allowAbsolutePaths) {
throw new SecurityValidationError('Absolute paths not allowed', 'ABSOLUTE_PATH_BLOCKED');
}
// Check against blocked patterns
for (const pattern of config.blockedPatterns) {
const regex = new RegExp(pattern, 'i');
if (regex.test(normalizedPath)) {
throw new SecurityValidationError(
`Path matches blocked pattern: ${pattern}`,
'BLOCKED_PATH_PATTERN'
);
}
}
// Check allowed base paths
if (config.allowedBasePaths.length > 0) {
const isAllowed = config.allowedBasePaths.some(basePath => {
const resolvedBase = resolve(basePath);
return normalizedPath.startsWith(resolvedBase);
});
if (!isAllowed) {
throw new SecurityValidationError('Path outside allowed directories', 'PATH_OUTSIDE_ALLOWED');
}
}
// Check if path exists
if (!existsSync(normalizedPath)) {
throw new SecurityValidationError(`Path does not exist: ${normalizedPath}`, 'PATH_NOT_FOUND');
}
// Validate file size for files
try {
const stats = statSync(normalizedPath);
if (stats.isFile() && stats.size > config.maxFileSize) {
throw new SecurityValidationError(
`File too large (max ${config.maxFileSize} bytes)`,
'FILE_TOO_LARGE'
);
}
} catch (error) {
throw new SecurityValidationError(`Cannot access path: ${error.message}`, 'ACCESS_ERROR');
}
logger.debug(`Path validated: ${inputPath} -> ${normalizedPath}`);
return normalizedPath;
}
/**
* Validate file extensions
* @param {string} filePath - Path to validate
* @param {Array<string>} allowedExtensions - Allowed extensions (empty array allows all)
* @param {Array<string>} blockedExtensions - Blocked extensions
* @throws {SecurityValidationError} If extension is not allowed
*/
export function validateFileExtension(filePath, allowedExtensions = [], blockedExtensions = []) {
const ext = filePath.toLowerCase().split('.').pop();
if (!ext) {
return; // No extension is ok
}
// Check blocked extensions first
if (blockedExtensions.includes(`.${ext}`)) {
throw new SecurityValidationError(
`File extension .${ext} is blocked for security`,
'BLOCKED_EXTENSION'
);
}
// Check allowed extensions if specified
if (allowedExtensions.length > 0 && !allowedExtensions.includes(`.${ext}`)) {
throw new SecurityValidationError(
`File extension .${ext} is not allowed`,
'EXTENSION_NOT_ALLOWED'
);
}
}
/**
* Validate array of file extensions
* @param {Array<string>} extensions - Extensions to validate
* @returns {Array<string>} Sanitized extensions
* @throws {SecurityValidationError} If any extension is invalid
*/
export function validateExtensions(extensions) {
if (!Array.isArray(extensions)) {
throw new SecurityValidationError('Extensions must be an array', 'INVALID_TYPE');
}
const sanitized = [];
for (const ext of extensions) {
if (typeof ext !== 'string') {
throw new SecurityValidationError('Extension must be a string', 'INVALID_TYPE');
}
// Ensure extension starts with dot
const normalizedExt = ext.startsWith('.') ? ext : `.${ext}`;
// Basic validation
if (!/^\.[a-zA-Z0-9]+$/.test(normalizedExt)) {
throw new SecurityValidationError(
`Invalid extension format: ${ext}`,
'INVALID_EXTENSION_FORMAT'
);
}
// Check against blocked extensions
if (DEFAULT_SECURITY_CONFIG.blockedExtensions.includes(normalizedExt)) {
throw new SecurityValidationError(
`File extension ${normalizedExt} is blocked for security`,
'BLOCKED_EXTENSION'
);
}
sanitized.push(normalizedExt);
}
return sanitized;
}
/**
* Validate numeric parameters
* @param {any} value - Value to validate
* @param {Object} constraints - Validation constraints
* @returns {number} Validated number
* @throws {SecurityValidationError} If value is invalid
*/
export function validateNumber(value, constraints = {}) {
const { min = 0, max = Number.MAX_SAFE_INTEGER, integer = false } = constraints;
if (typeof value !== 'number' && typeof value !== 'string') {
throw new SecurityValidationError('Value must be a number', 'INVALID_TYPE');
}
const num = Number(value);
if (isNaN(num)) {
throw new SecurityValidationError('Value is not a valid number', 'INVALID_NUMBER');
}
if (integer && !Number.isInteger(num)) {
throw new SecurityValidationError('Value must be an integer', 'NOT_INTEGER');
}
if (num < min) {
throw new SecurityValidationError(`Value must be at least ${min}`, 'VALUE_TOO_LOW');
}
if (num > max) {
throw new SecurityValidationError(`Value must be at most ${max}`, 'VALUE_TOO_HIGH');
}
return num;
}
/**
* Validate and sanitize analysis parameters for DCD/Simian tools
* @param {Object} params - Parameters to validate
* @param {Object} options - Validation options
* @returns {Object} Sanitized parameters
* @throws {SecurityValidationError} If any parameter is invalid
*/
export function validateAnalysisParams(params, options = {}) {
const validated = {};
// Validate required path parameter
if (!params.path) {
throw new SecurityValidationError('Path parameter is required', 'MISSING_REQUIRED_PARAM');
}
validated.path = validatePath(params.path, options.pathOptions);
// Validate optional parameters
if (params.matchLength !== undefined) {
validated.matchLength = validateNumber(params.matchLength, {
min: 1,
max: 1000,
integer: true,
});
}
if (params.threshold !== undefined) {
validated.threshold = validateNumber(params.threshold, {
min: 2,
max: 1000,
integer: true,
});
}
if (params.fuzziness !== undefined) {
validated.fuzziness = validateNumber(params.fuzziness, {
min: 0,
max: 255,
integer: true,
});
}
if (params.extensions !== undefined) {
validated.extensions = validateExtensions(params.extensions);
}
if (params.minOccurrences !== undefined) {
validated.minOccurrences = validateNumber(params.minOccurrences, {
min: 2,
max: 100,
integer: true,
});
}
if (params.complexityThreshold !== undefined) {
validated.complexityThreshold = validateNumber(params.complexityThreshold, {
min: 1,
max: 1000,
integer: true,
});
}
// Validate options object
if (params.options !== undefined) {
if (typeof params.options !== 'object' || Array.isArray(params.options)) {
throw new SecurityValidationError('Options must be an object', 'INVALID_TYPE');
}
validated.options = params.options; // Simple object validation for now
}
logger.debug('Parameters validated successfully');
return validated;
}
/**
* Security configuration for different environments
*/
export const SECURITY_PROFILES = {
development: {
allowAbsolutePaths: true,
maxFileSize: 500 * 1024 * 1024, // 500MB for dev
allowedBasePaths: [], // Allow any path in development
},
production: {
allowAbsolutePaths: false,
maxFileSize: 100 * 1024 * 1024, // 100MB for production
allowedBasePaths: [process.cwd()], // Only allow current working directory and subdirs
},
strict: {
allowAbsolutePaths: false,
maxFileSize: 50 * 1024 * 1024, // 50MB for strict mode
allowedBasePaths: [process.cwd()],
blockedPatterns: [
...DEFAULT_SECURITY_CONFIG.blockedPatterns,
'node_modules/',
'\\.git/',
'\\.env',
'secrets/',
'private/',
],
},
};
/**
* Get security configuration for environment
* @param {string} environment - Environment name
* @returns {Object} Security configuration
*/
export function getSecurityConfig(environment = 'development') {
// Default to production (strict) for unknown environments for security
const profile = SECURITY_PROFILES[environment] || SECURITY_PROFILES.production;
return { ...DEFAULT_SECURITY_CONFIG, ...profile };
}