@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
162 lines (144 loc) • 6.34 kB
JavaScript
/**
* Security utilities for path sanitization and validation
* Protects against path traversal attacks and ensures safe file operations
*/
import { resolve, normalize, isAbsolute, join } from 'path';
import * as output from './output.js';
/**
* Sanitizes a screenshot name to prevent path traversal and ensure safe file naming
* @param {string} name - Original screenshot name
* @param {number} maxLength - Maximum allowed length (default: 255)
* @param {boolean} allowSlashes - Whether to allow forward slashes (for browser version strings)
* @returns {string} Sanitized screenshot name
*/
export function sanitizeScreenshotName(name, maxLength = 255, allowSlashes = false) {
if (typeof name !== 'string' || name.length === 0) {
throw new Error('Screenshot name must be a non-empty string');
}
if (name.length > maxLength) {
throw new Error(`Screenshot name exceeds maximum length of ${maxLength} characters`);
}
// Block directory traversal patterns
if (name.includes('..') || name.includes('\\')) {
throw new Error('Screenshot name contains invalid path characters');
}
// Block forward slashes unless explicitly allowed (e.g., for browser version strings)
if (!allowSlashes && name.includes('/')) {
throw new Error('Screenshot name contains invalid path characters');
}
// Block absolute paths
if (isAbsolute(name)) {
throw new Error('Screenshot name cannot be an absolute path');
}
// Allow only safe characters: alphanumeric, hyphens, underscores, dots, and optionally slashes
// Replace other characters with underscores
let allowedChars = allowSlashes ? /[^a-zA-Z0-9._/-]/g : /[^a-zA-Z0-9._-]/g;
let sanitized = name.replace(allowedChars, '_');
// Prevent names that start with dots (hidden files)
if (sanitized.startsWith('.')) {
sanitized = 'file_' + sanitized;
}
// Ensure we have a valid filename
if (sanitized.length === 0 || sanitized === '.' || sanitized === '..') {
sanitized = 'unnamed_screenshot';
}
return sanitized;
}
/**
* Validates that a path stays within the allowed working directory bounds
* @param {string} targetPath - Path to validate
* @param {string} workingDir - Working directory that serves as the security boundary
* @returns {string} Resolved and normalized path if valid
* @throws {Error} If path is invalid or outside bounds
*/
export function validatePathSecurity(targetPath, workingDir) {
if (typeof targetPath !== 'string' || targetPath.length === 0) {
throw new Error('Path must be a non-empty string');
}
if (typeof workingDir !== 'string' || workingDir.length === 0) {
throw new Error('Working directory must be a non-empty string');
}
// Normalize and resolve both paths
let resolvedWorkingDir = resolve(normalize(workingDir));
let resolvedTargetPath = resolve(normalize(targetPath));
// Ensure the target path starts with the working directory
if (!resolvedTargetPath.startsWith(resolvedWorkingDir)) {
output.warn(`Path traversal attempt blocked: ${targetPath} (resolved: ${resolvedTargetPath}) is outside working directory: ${resolvedWorkingDir}`);
throw new Error('Path is outside the allowed working directory');
}
return resolvedTargetPath;
}
/**
* Safely constructs a path within the working directory
* @param {string} workingDir - Base working directory
* @param {...string} pathSegments - Path segments to join
* @returns {string} Safely constructed path
* @throws {Error} If resulting path would be outside working directory
*/
export function safePath(workingDir, ...pathSegments) {
if (pathSegments.length === 0) {
return validatePathSecurity(workingDir, workingDir);
}
// Sanitize each path segment
let sanitizedSegments = pathSegments.map(segment => {
if (typeof segment !== 'string') {
throw new Error('Path segment must be a string');
}
// Block directory traversal in segments
if (segment.includes('..')) {
throw new Error('Path segment contains directory traversal sequence');
}
return segment;
});
let targetPath = join(workingDir, ...sanitizedSegments);
return validatePathSecurity(targetPath, workingDir);
}
/**
* Validates screenshot properties object for safe values
* @param {Object} properties - Properties to validate
* @returns {Object} Validated properties object
*/
export function validateScreenshotProperties(properties = {}) {
if (properties === null || typeof properties !== 'object') {
return {};
}
let validated = {};
// Validate common properties with safe constraints
if (properties.browser && typeof properties.browser === 'string') {
try {
// Extract browser name without version (e.g., "Chrome/139.0.7258.138" -> "Chrome")
let browserName = properties.browser.split('/')[0];
validated.browser = sanitizeScreenshotName(browserName, 50);
} catch (error) {
// Skip invalid browser names, don't include them
output.warn(`Invalid browser name '${properties.browser}': ${error.message}`);
}
}
if (properties.viewport && typeof properties.viewport === 'object') {
let viewport = {};
if (typeof properties.viewport.width === 'number' && properties.viewport.width > 0 && properties.viewport.width <= 10000) {
viewport.width = Math.floor(properties.viewport.width);
}
if (typeof properties.viewport.height === 'number' && properties.viewport.height > 0 && properties.viewport.height <= 10000) {
viewport.height = Math.floor(properties.viewport.height);
}
if (Object.keys(viewport).length > 0) {
validated.viewport = viewport;
}
}
// Allow other safe string properties but sanitize them
for (let [key, value] of Object.entries(properties)) {
if (key === 'browser' || key === 'viewport') continue; // Already handled
if (typeof key === 'string' && key.length <= 50 && /^[a-zA-Z0-9_-]+$/.test(key)) {
if (typeof value === 'string' && value.length <= 200) {
// Store sanitized version of string values
validated[key] = value.replace(/[<>&"']/g, ''); // Basic HTML entity prevention
} else if (typeof value === 'number' && !isNaN(value) && isFinite(value)) {
validated[key] = value;
} else if (typeof value === 'boolean') {
validated[key] = value;
}
}
}
return validated;
}