Files
dashcaddy/dashcaddy-api/input-validator.js
Sami f61e85d9a7 Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend,
DashCA certificate distribution, installer script, and deployment skills.
2026-03-05 02:26:12 -08:00

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 = { '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' };
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
};