607 lines
18 KiB
JavaScript
607 lines
18 KiB
JavaScript
/**
|
|
* 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<string>} allowedRoots - Array of allowed root directories
|
|
* @param {object} auditLogger - Optional audit logger for security events
|
|
* @returns {Promise<string>} - 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,
|
|
};
|