/** * Input validation and sanitization utilities for server-side security */ import path from 'path'; /** * Validates Docker image name against Docker naming conventions * @param {string} image - Docker image name * @returns {boolean} - True if valid */ function isValidImageName(image) { if (!image || typeof image !== 'string') return false; // Docker image name pattern: [registry/][namespace/]name[:tag] // Allowed characters: lowercase letters, numbers, dots, hyphens, underscores, slashes, colons const imagePattern = /^([a-z0-9._-]+\/)*[a-z0-9._-]+(:[a-zA-Z0-9._-]+)?$/; // Max length check if (image.length > 255) return false; return imagePattern.test(image); } /** * Validates container name against Docker naming conventions * @param {string} name - Container name * @returns {boolean} - True if valid */ function isValidContainerName(name) { if (!name || typeof name !== 'string') return false; // Container names: alphanumeric, dashes, underscores, dots // Must start and end with alphanumeric const namePattern = /^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/; // Length constraints (1-63 characters for hostname compatibility) if (name.length < 1 || name.length > 63) return false; return namePattern.test(name); } /** * Sanitizes environment variable name * @param {string} name - Environment variable name * @returns {string|null} - Sanitized name or null if invalid */ function sanitizeEnvVarName(name) { if (!name || typeof name !== 'string') return null; // Environment variable names: letters, numbers, underscores // Must start with letter or underscore const sanitized = name.trim(); if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(sanitized)) return null; if (sanitized.length > 100) return null; // Reasonable limit return sanitized; } /** * Sanitizes environment variable value * @param {string} value - Environment variable value * @returns {string} - Sanitized value */ function sanitizeEnvVarValue(value) { if (value === null || value === undefined) return ''; if (typeof value !== 'string') return String(value); // Remove null bytes and control characters (except newline, tab) return value.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, '').trim(); } /** * Validates port mapping format * @param {string} portMapping - Port mapping string (e.g., "8080:80/tcp") * @returns {boolean} - True if valid */ function isValidPortMapping(portMapping) { if (!portMapping || typeof portMapping !== 'string') return false; // Format: [hostPort:]containerPort[/protocol] const portPattern = /^(\d+)?:?\d+\/(tcp|udp)$/; if (!portPattern.test(portMapping)) return false; const parts = portMapping.split(':'); if (parts.length === 2) { const [hostPort, rest] = parts; const port = parseInt(hostPort, 10); if (port < 1 || port > 65535) return false; } const containerPort = parseInt(parts[parts.length - 1].split('/')[0], 10); return containerPort >= 1 && containerPort <= 65535; } /** * Mount option suffix (Docker bind mode flags). * @param {string} part */ function isMountModePart(part) { if (!part || typeof part !== 'string') return false; // e.g. ro, rw, ro,Z, rw,z, shared, rprivate, … return /^(?:ro|rw|z|Z|shared|rshared|slave|rslave|private|rprivate)(?:,(?:ro|rw|z|Z|shared|rshared|slave|rslave|private|rprivate))*$/i.test( part.trim() ); } /** * Validates volume mount format. * Accepts bind mounts and named volumes: * /host/path:/container/path * /host/path:/container/path:ro * volume-name:/container/path * volume-name:/container/path:rw * /host:/container:ro,Z * @param {string} volume - Volume mount string * @returns {boolean} - True if valid */ function isValidVolumeMount(volume) { if (!volume || typeof volume !== 'string') return false; if (!volume.includes(':')) return false; // Block path traversal in any segment if (volume.includes('..')) return false; const raw = volume.trim(); const parts = raw.split(':'); if (parts.length < 2) return false; let source; let dest; if (parts.length >= 3 && isMountModePart(parts[parts.length - 1])) { dest = parts[parts.length - 2]; source = parts.slice(0, -2).join(':'); } else if (parts.length === 2) { source = parts[0]; dest = parts[1]; } else if (parts.length > 2) { // e.g. Windows drive or unusual source with colons: join all but last as source dest = parts[parts.length - 1]; source = parts.slice(0, -1).join(':'); } else { return false; } if (!source || !dest) return false; // Container path must be absolute if (!dest.startsWith('/')) return false; // Named volume (Docker volume name) OR host path (absolute/relative/~ / Windows drive) const namedVol = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; const isNamed = namedVol.test(source); const isHostPath = source.startsWith('/') || source.startsWith('./') || source.startsWith('~/') || source === '~' || /^[A-Za-z]:[\\/]/.test(source); return isNamed || isHostPath; } /** * Sanitizes label key * @param {string} key - Label key * @returns {string|null} - Sanitized key or null if invalid */ function sanitizeLabelKey(key) { if (!key || typeof key !== 'string') return null; // Docker label keys: alphanumeric, dots, hyphens, underscores const sanitized = key.trim(); if (!/^[a-zA-Z0-9._-]+$/.test(sanitized)) return null; if (sanitized.length > 250) return null; return sanitized; } /** * Sanitizes label value * @param {string} value - Label value * @returns {string} - Sanitized value */ function sanitizeLabelValue(value) { if (value === null || value === undefined) return ''; if (typeof value !== 'string') return String(value); // Remove null bytes return value.replace(/\x00/g, '').trim(); } /** * Validates hostname * @param {string} hostname - Hostname string * @returns {boolean} - True if valid */ function isValidHostname(hostname) { if (!hostname || typeof hostname !== 'string') return false; // Hostname: alphanumeric, dots, hyphens // Max 253 characters total, each label max 63 const hostnamePattern = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; if (hostname.length > 253) return false; return hostnamePattern.test(hostname); } /** * Validates DNS server IP address * @param {string} dns - DNS server IP * @returns {boolean} - True if valid */ function isValidDnsServer(dns) { if (!dns || typeof dns !== 'string') return false; // IPv4 pattern const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/; if (ipv4Pattern.test(dns)) { const parts = dns.split('.'); return parts.every(part => { const num = parseInt(part, 10); return num >= 0 && num <= 255; }); } // IPv6 pattern (simplified) const ipv6Pattern = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/; return ipv6Pattern.test(dns); } /** * Sanitizes string input by removing dangerous characters * @param {string} input - Input string * @param {number} maxLength - Maximum length * @returns {string} - Sanitized string */ function sanitizeString(input, maxLength = 1000) { if (input === null || input === undefined) return ''; if (typeof input !== 'string') return String(input); // Remove null bytes and control characters let sanitized = input.replace(/[\x00-\x1F\x7F]/g, '').trim(); // Enforce max length if (sanitized.length > maxLength) { sanitized = sanitized.substring(0, maxLength); } return sanitized; } /** * Validates numeric input within range * @param {any} value - Input value * @param {number} min - Minimum value * @param {number} max - Maximum value * @returns {number|null} - Validated number or null */ function validateNumber(value, min = -Infinity, max = Infinity) { if (value === null || value === undefined || value === '') return null; const num = typeof value === 'number' ? value : parseFloat(value); if (isNaN(num)) return null; if (num < min || num > max) return null; return num; } /** * Validates URL format * @param {string} url - URL string * @returns {boolean} - True if valid URL */ function isValidUrl(url) { if (!url || typeof url !== 'string') return false; try { const urlObj = new URL(url); return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'; } catch { return false; } } /** * Validates email format * @param {string} email - Email string * @returns {boolean} - True if valid email */ function isValidEmail(email) { if (!email || typeof email !== 'string') return false; const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailPattern.test(email); } /** * Validates IP address (IPv4 or IPv6) * @param {string} ip - IP address string * @returns {boolean} - True if valid IP */ function isValidIpAddress(ip) { if (!ip || typeof ip !== 'string') return false; // IPv4 const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/; if (ipv4Pattern.test(ip)) { const parts = ip.split('.'); return parts.every(part => { const num = parseInt(part, 10); return num >= 0 && num <= 255; }); } // IPv6 (simplified) const ipv6Pattern = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/; return ipv6Pattern.test(ip); } /** * Validates that a value is in a list of allowed options * @param {any} value - Value to validate * @param {Array} options - Array of allowed options * @returns {boolean} - True if value is in options */ function isValidSelectOption(value, options) { if (!options || !Array.isArray(options)) return true; // If no options specified, allow any value // Handle both simple arrays and object arrays with value property const optionValues = options.map(opt => { if (typeof opt === 'object' && opt.value !== undefined) { return String(opt.value); } return String(opt); }); return optionValues.includes(String(value)); } /** * Validates that preset values haven't been modified * @param {Object} envVar - Environment variable object with name, value, and preset flag * @param {Object} templateEnv - Template environment variable definition * @returns {boolean} - True if preset value is unchanged or not preset */ function isValidPresetValue(envVar, templateEnv) { if (!templateEnv || templateEnv.preset !== true) return true; // Not a preset, allow changes if (!envVar.preset) return false; // Preset value was marked as non-preset const expectedValue = templateEnv.default || templateEnv.set || ''; return String(envVar.value) === String(expectedValue); } /** * Validates numeric range * @param {any} value - Value to validate * @param {number} min - Minimum value (optional) * @param {number} max - Maximum value (optional) * @returns {boolean} - True if value is within range */ function isValidNumericRange(value, min, max) { if (value === null || value === undefined || value === '') return true; // Empty is valid (handled by required check) const num = typeof value === 'number' ? value : parseFloat(value); if (isNaN(num)) return false; if (min !== undefined && num < min) return false; if (max !== undefined && num > max) return false; return true; } /** * Validates directory path for file browser * @param {string} path - Directory path * @returns {boolean} - True if valid */ function isValidDirectoryPath(path) { if (!path || typeof path !== 'string') return false; // Prevent path traversal if (path.includes('..')) return false; // Must be absolute path if (!path.startsWith('/')) return false; // Basic length check if (path.length > 4096) return false; return true; } /** * Sanitizes directory path for safe filesystem access * @param {string} path - Directory path * @returns {string} - Sanitized path */ function sanitizeDirectoryPath(inputPath) { if (!inputPath || typeof inputPath !== 'string') { console.warn('[WARN] sanitizeDirectoryPath: Invalid input, returning /'); return '/'; } // Remove null bytes and control characters let sanitized = inputPath.replace(/[\x00-\x1F\x7F]/g, '').trim(); // If empty after cleaning, return root if (!sanitized) { console.warn('[WARN] sanitizeDirectoryPath: Empty after cleaning, returning /'); return '/'; } // Resolve to absolute path and prevent traversal try { // If already absolute, use as-is (path.resolve will normalize it) // If relative, resolve from current working directory sanitized = path.resolve(sanitized); console.log(`[DEBUG] sanitizeDirectoryPath: Resolved "${inputPath}" to "${sanitized}"`); // Ensure it's still absolute after resolution if (!path.isAbsolute(sanitized)) { console.warn(`[WARN] sanitizeDirectoryPath: Resolved path is not absolute: "${sanitized}", returning /`); return '/'; } // Additional safety: prevent access to sensitive directories // This is a basic check - you may want to add more restrictions const sensitivePaths = ['/etc', '/sys', '/proc', '/dev']; for (const sensitive of sensitivePaths) { if (sanitized.startsWith(sensitive) && sanitized !== sensitive) { // Allow root level but not deeper console.warn(`[WARN] sanitizeDirectoryPath: Blocked access to sensitive path: "${sanitized}"`); return '/'; } } console.log(`[DEBUG] sanitizeDirectoryPath: Final sanitized path: "${sanitized}"`); return sanitized; } catch (error) { console.warn(`[WARN] Path sanitization error for "${inputPath}":`, error.message); return '/'; } } export { isValidImageName, isValidContainerName, sanitizeEnvVarName, sanitizeEnvVarValue, isValidPortMapping, isValidVolumeMount, sanitizeLabelKey, sanitizeLabelValue, isValidHostname, isValidDnsServer, sanitizeString, validateNumber, isValidUrl, isValidEmail, isValidIpAddress, isValidSelectOption, isValidPresetValue, isValidNumericRange, isValidDirectoryPath, sanitizeDirectoryPath };