Files
peardock/server/utils/validation.js
T
2026-07-16 00:01:07 -04:00

474 lines
14 KiB
JavaScript

/**
* 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
};