/** * Input Validation Module for DashCaddy * Comprehensive validation to prevent injection attacks and ensure data integrity */ const path = require('path'); const validator = require('validator'); class ValidationError extends Error { constructor(message, field = null) { super(message); this.name = 'ValidationError'; this.field = field; this.statusCode = 400; } } /** * Validate DNS record data */ function validateDNSRecord(data) { const errors = []; // Validate subdomain if (!data.subdomain || typeof data.subdomain !== 'string') { errors.push({ field: 'subdomain', message: 'Subdomain is required' }); } else { // DNS label validation: alphanumeric and hyphens, 1-63 chars, no leading/trailing hyphens const subdomainRegex = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i; if (!subdomainRegex.test(data.subdomain)) { errors.push({ field: 'subdomain', message: 'Invalid subdomain format. Use only letters, numbers, and hyphens (1-63 chars)', }); } // Prevent DNS injection attempts const dangerousChars = [';', '&', '|', '`', '$', '(', ')', '<', '>', '\n', '\r', '\\']; if (dangerousChars.some(char => data.subdomain.includes(char))) { errors.push({ field: 'subdomain', message: 'Subdomain contains invalid characters' }); } } // Validate domain if (data.domain && typeof data.domain === 'string') { if (!validator.isFQDN(data.domain, { require_tld: false })) { errors.push({ field: 'domain', message: 'Invalid domain format' }); } } // Validate IP address if (!data.ip || typeof data.ip !== 'string') { errors.push({ field: 'ip', message: 'IP address is required' }); } else { if (!validator.isIP(data.ip, 4) && !validator.isIP(data.ip, 6)) { errors.push({ field: 'ip', message: 'Invalid IP address format' }); } // Prevent SSRF by blocking private IPs in certain contexts if (data.blockPrivateIPs && isPrivateIP(data.ip)) { errors.push({ field: 'ip', message: 'Private IP addresses are not allowed in this context' }); } } // Validate TTL if provided if (data.ttl !== undefined) { const ttl = parseInt(data.ttl, 10); if (isNaN(ttl) || ttl < 60 || ttl > 86400) { errors.push({ field: 'ttl', message: 'TTL must be between 60 and 86400 seconds' }); } } if (errors.length > 0) { const error = new ValidationError('DNS record validation failed'); error.errors = errors; throw error; } return { subdomain: data.subdomain.toLowerCase().trim(), domain: data.domain ? data.domain.toLowerCase().trim() : null, ip: data.ip.trim(), ttl: data.ttl ? parseInt(data.ttl, 10) : 3600, }; } /** * Validate Docker container deployment data */ function validateDockerDeployment(data) { const errors = []; // Validate container name if (!data.name || typeof data.name !== 'string') { errors.push({ field: 'name', message: 'Container name is required' }); } else { // Docker name validation: alphanumeric, underscores, periods, hyphens const nameRegex = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; if (!nameRegex.test(data.name)) { errors.push({ field: 'name', message: 'Invalid container name. Use only letters, numbers, underscores, periods, and hyphens', }); } if (data.name.length > 255) { errors.push({ field: 'name', message: 'Container name too long (max 255 chars)' }); } } // Validate Docker image if (!data.image || typeof data.image !== 'string') { errors.push({ field: 'image', message: 'Docker image is required' }); } else { // Docker image validation: registry/repo:tag format // Allow: alpine, nginx:latest, docker.io/library/nginx:1.21, ghcr.io/user/repo:tag const imageRegex = /^(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?::[0-9]{1,5})?\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[a-z0-9]+(?:[._-][a-z0-9]+)*)?$/i; if (!imageRegex.test(data.image)) { errors.push({ field: 'image', message: 'Invalid Docker image format', }); } // Block dangerous image patterns const dangerousPatterns = [';', '&', '|', '`', '$', '$(', '&&', '||', '\n', '\r']; if (dangerousPatterns.some(pattern => data.image.includes(pattern))) { errors.push({ field: 'image', message: 'Docker image contains invalid characters' }); } if (data.image.length > 512) { errors.push({ field: 'image', message: 'Docker image name too long' }); } } // Validate ports if (data.ports) { if (!Array.isArray(data.ports)) { errors.push({ field: 'ports', message: 'Ports must be an array' }); } else { data.ports.forEach((port, index) => { if (typeof port === 'string') { // Format: "8080:80" or "8080:80/tcp" const portRegex = /^(\d{1,5}):(\d{1,5})(?:\/(tcp|udp))?$/; if (!portRegex.test(port)) { errors.push({ field: `ports[${index}]`, message: 'Invalid port format. Use "host:container" or "host:container/protocol"', }); } else { const [, hostPort, containerPort] = port.match(portRegex); if (!isValidPort(hostPort) || !isValidPort(containerPort)) { errors.push({ field: `ports[${index}]`, message: 'Port numbers must be between 1 and 65535' }); } } } else if (typeof port === 'number') { if (!isValidPort(port)) { errors.push({ field: `ports[${index}]`, message: 'Port number must be between 1 and 65535' }); } } else { errors.push({ field: `ports[${index}]`, message: 'Invalid port type' }); } }); } } // Validate volumes if (data.volumes) { if (!Array.isArray(data.volumes)) { errors.push({ field: 'volumes', message: 'Volumes must be an array' }); } else { data.volumes.forEach((volume, index) => { if (typeof volume !== 'string') { errors.push({ field: `volumes[${index}]`, message: 'Volume must be a string' }); } else { // Validate volume format and prevent path traversal const volumeErrors = validateVolumePath(volume, index); errors.push(...volumeErrors); } }); } } // Validate environment variables if (data.environment) { if (typeof data.environment !== 'object' || Array.isArray(data.environment)) { errors.push({ field: 'environment', message: 'Environment must be an object' }); } else { Object.entries(data.environment).forEach(([key, value]) => { // Validate env var name const envKeyRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/; if (!envKeyRegex.test(key)) { errors.push({ field: `environment.${key}`, message: 'Invalid environment variable name', }); } // Ensure value is string or number if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { errors.push({ field: `environment.${key}`, message: 'Environment variable value must be string, number, or boolean', }); } }); } } if (errors.length > 0) { const error = new ValidationError('Docker deployment validation failed'); error.errors = errors; throw error; } return { name: data.name.trim(), image: data.image.trim(), ports: data.ports || [], volumes: data.volumes || [], environment: data.environment || {}, }; } /** * Validate file path to prevent directory traversal */ function validateFilePath(filePath, allowedBasePaths = []) { if (!filePath || typeof filePath !== 'string') { throw new ValidationError('File path is required', 'path'); } // Normalize path const normalized = path.normalize(filePath); // Check for directory traversal attempts if (normalized.includes('..') || normalized.includes('~')) { throw new ValidationError('Path traversal detected', 'path'); } // Block absolute paths to sensitive locations const blockedPaths = [ '/etc', '/sys', '/proc', '/root', 'C:\\Windows', 'C:\\Program Files', '/var/run', '/var/lib/docker', ]; const lowerPath = normalized.toLowerCase(); if (blockedPaths.some(blocked => lowerPath.startsWith(blocked.toLowerCase()))) { throw new ValidationError('Access to this path is not allowed', 'path'); } // If allowed base paths specified, ensure path is within them if (allowedBasePaths.length > 0) { const isAllowed = allowedBasePaths.some(basePath => { const normalizedBase = path.normalize(basePath); return normalized.startsWith(normalizedBase); }); if (!isAllowed) { throw new ValidationError('Path is outside allowed directories', 'path'); } } return normalized; } /** * Validate volume path for Docker */ function validateVolumePath(volume, index) { const errors = []; // Format: /host/path:/container/path or /host/path:/container/path:ro const volumeRegex = /^([^:]+):([^:]+)(?::(ro|rw|z|Z))?$/; const match = volume.match(volumeRegex); if (!match) { errors.push({ field: `volumes[${index}]`, message: 'Invalid volume format. Use "host:container" or "host:container:mode"', }); return errors; } const [, hostPath, containerPath, mode] = match; // Validate host path try { validateFilePath(hostPath); } catch (error) { errors.push({ field: `volumes[${index}].hostPath`, message: `Invalid host path: ${error.message}`, }); } // Validate container path if (containerPath.includes('..') || !path.isAbsolute(containerPath)) { errors.push({ field: `volumes[${index}].containerPath`, message: 'Container path must be absolute and not contain ..', }); } // Validate mode if present if (mode && !['ro', 'rw', 'z', 'Z'].includes(mode)) { errors.push({ field: `volumes[${index}].mode`, message: 'Invalid volume mode. Use ro, rw, z, or Z', }); } return errors; } /** * Validate URL */ function validateURL(url, options = {}) { if (!url || typeof url !== 'string') { throw new ValidationError('URL is required', 'url'); } const validatorOptions = { protocols: options.protocols || ['http', 'https'], require_protocol: options.requireProtocol !== false, require_valid_protocol: true, allow_underscores: false, ...options, }; if (!validator.isURL(url, validatorOptions)) { throw new ValidationError('Invalid URL format', 'url'); } // Block localhost/private IPs if specified if (options.blockPrivate) { try { const urlObj = new URL(url); if (urlObj.hostname === 'localhost' || urlObj.hostname === '127.0.0.1' || isPrivateIP(urlObj.hostname)) { throw new ValidationError('Private URLs are not allowed', 'url'); } } catch (e) { if (e instanceof ValidationError) throw e; throw new ValidationError('Invalid URL', 'url'); } } return url; } /** * Validate API token format */ function validateToken(token) { if (!token || typeof token !== 'string') { throw new ValidationError('Token is required', 'token'); } // Token should be alphanumeric with possible special chars, reasonable length if (token.length < 8) { throw new ValidationError('Token too short (minimum 8 characters)', 'token'); } if (token.length > 512) { throw new ValidationError('Token too long (maximum 512 characters)', 'token'); } // Block obvious injection attempts const dangerousPatterns = [';', '&', '|', '`', '\n', '\r', '$(', '&&']; if (dangerousPatterns.some(pattern => token.includes(pattern))) { throw new ValidationError('Token contains invalid characters', 'token'); } return token.trim(); } /** * Validate service configuration */ function validateServiceConfig(service) { const errors = []; // Validate ID if (!service.id || typeof service.id !== 'string') { errors.push({ field: 'id', message: 'Service ID is required' }); } else { const idRegex = /^[a-z0-9-_]+$/i; if (!idRegex.test(service.id)) { errors.push({ field: 'id', message: 'Invalid service ID format' }); } } // Validate name if (!service.name || typeof service.name !== 'string') { errors.push({ field: 'name', message: 'Service name is required' }); } else if (service.name.length > 100) { errors.push({ field: 'name', message: 'Service name too long (max 100 chars)' }); } // Validate URL if provided if (service.url) { try { validateURL(service.url); } catch (error) { errors.push({ field: 'url', message: error.message }); } } // Validate port if provided if (service.port !== undefined && !isValidPort(service.port)) { errors.push({ field: 'port', message: 'Invalid port number' }); } if (errors.length > 0) { const error = new ValidationError('Service configuration validation failed'); error.errors = errors; throw error; } return service; } /** * Helper: Check if port is valid */ function isValidPort(port) { const portNum = typeof port === 'string' ? parseInt(port, 10) : port; return !isNaN(portNum) && portNum >= 1 && portNum <= 65535; } /** * Helper: Check if IP is private */ function isPrivateIP(ip) { // IPv4 private ranges const privateRanges = [ /^10\./, /^172\.(1[6-9]|2[0-9]|3[0-1])\./, /^192\.168\./, /^127\./, /^169\.254\./, /^::1$/, /^fc00:/, /^fe80:/, ]; return privateRanges.some(range => range.test(ip)); } /** * Sanitize string for safe display (prevent XSS) */ function sanitizeString(str, maxLength = 1000) { if (typeof str !== 'string') return ''; return str .slice(0, maxLength) .replace(/[<>'"]/g, char => { const entities = { '<': '<', '>': '>', "'": ''', '"': '"' }; return entities[char] || char; }); } /** * Validate secure path with realpath resolution and traversal detection * This is CRITICAL for preventing path traversal attacks * @param {string} requestedPath - The path requested by the user * @param {Array} allowedRoots - Array of allowed root directories * @param {object} auditLogger - Optional audit logger for security events * @returns {Promise} - Resolved safe path */ async function validateSecurePath(requestedPath, allowedRoots, auditLogger = null) { const fs = require('fs').promises; if (!requestedPath || typeof requestedPath !== 'string') { throw new ValidationError('Path is required', 'path'); } if (!Array.isArray(allowedRoots) || allowedRoots.length === 0) { throw new ValidationError('No allowed roots configured', 'path'); } // Check for null byte injection if (requestedPath.includes('\0')) { if (auditLogger) { auditLogger.logSecurityEvent('path_traversal_blocked', { requestedPath, reason: 'null_byte_detected', severity: 'high', }); } throw new ValidationError('Invalid path - null byte detected', 'path'); } // Check for encoded traversal sequences const decodedPath = decodeURIComponent(requestedPath); const suspiciousPatterns = [ /\.\./, // .. /%2e%2e/i, // URL encoded .. /\.\%2f/i, // .%2F (encoded ./) /%2e\./i, // %2E. /\.\\/, // .\ (Windows) /%5c/i, // URL encoded backslash ]; if (suspiciousPatterns.some(pattern => pattern.test(requestedPath)) || suspiciousPatterns.some(pattern => pattern.test(decodedPath))) { if (auditLogger) { auditLogger.logSecurityEvent('path_traversal_blocked', { requestedPath, decodedPath, reason: 'traversal_sequence_detected', severity: 'high', }); } throw new ValidationError('Path traversal detected', 'path'); } // Normalize the path for the current platform const normalized = path.normalize(requestedPath); // Try to resolve the real path (follows symlinks) let realPath; try { realPath = await fs.realpath(normalized); } catch (error) { if (error.code === 'ENOENT') { // Path doesn't exist - that's okay, just use normalized path // But we still need to check if parent exists and is within allowed roots const parentDir = path.dirname(normalized); try { const parentReal = await fs.realpath(parentDir); // Construct the real path using the resolved parent realPath = path.join(parentReal, path.basename(normalized)); } catch (parentError) { if (parentError.code === 'ENOENT') { // Parent doesn't exist either - use normalized path realPath = normalized; } else if (parentError.code === 'EACCES') { throw new ValidationError('Access denied to path', 'path'); } else { throw parentError; } } } else if (error.code === 'EACCES') { throw new ValidationError('Access denied to path', 'path'); } else { throw error; } } // Normalize for cross-platform comparison (Windows is case-insensitive) const isWindows = process.platform === 'win32'; const normalizePath = (p) => { const normalized = path.normalize(p).replace(/\\/g, '/'); return isWindows ? normalized.toLowerCase() : normalized; }; const normalizedReal = normalizePath(realPath); // Check if the resolved path is within any allowed root const isWithinAllowedRoot = allowedRoots.some(root => { const normalizedRoot = normalizePath(root); return normalizedReal.startsWith(normalizedRoot); }); if (!isWithinAllowedRoot) { if (auditLogger) { auditLogger.logSecurityEvent('path_traversal_blocked', { requestedPath, realPath, allowedRoots, reason: 'outside_allowed_roots', severity: 'critical', }); } throw new ValidationError('Access denied - path is outside allowed directories', 'path'); } return realPath; } module.exports = { ValidationError, validateDNSRecord, validateDockerDeployment, validateVolumePath, validateFilePath, validateURL, validateToken, validateServiceConfig, sanitizeString, isValidPort, isPrivateIP, validateSecurePath, };