Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
606
dashcaddy-api/input-validator.js
Normal file
606
dashcaddy-api/input-validator.js
Normal file
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
Reference in New Issue
Block a user