UNPKG

@vizzly-testing/cli

Version:

Visual review platform for UI developers and designers

219 lines (196 loc) 8.74 kB
/** * Security utilities for path sanitization and validation * Protects against path traversal attacks and ensures safe file operations */ import { isAbsolute, join, normalize, resolve } from 'node: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 */ /** * Validate screenshot name for security (no transformations, just validation) * Throws if name contains path traversal or other dangerous patterns * * @param {string} name - Screenshot name to validate * @param {number} maxLength - Maximum allowed length * @returns {string} The original name (unchanged) if valid * @throws {Error} If name contains dangerous patterns */ export function validateScreenshotName(name, maxLength = 255) { 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 (path separators) if (name.includes('/')) { throw new Error('Screenshot name cannot contain forward slashes'); } // Block absolute paths if (isAbsolute(name)) { throw new Error('Screenshot name cannot be an absolute path'); } // Return the original name unchanged - validation only! return name; } /** * Validate screenshot name for security (allows spaces, preserves original name) * * This function only validates for security - it does NOT transform spaces. * Spaces are preserved so that: * 1. generateScreenshotSignature() uses the original name with spaces (matches cloud) * 2. generateBaselineFilename() handles space→hyphen conversion (matches cloud) * * Flow: "VBtn dark" → sanitize → "VBtn dark" → signature: "VBtn dark|1265||" → filename: "VBtn-dark_hash.png" * * @param {string} name - Screenshot name to validate * @param {number} maxLength - Maximum allowed length (default: 255) * @param {boolean} allowSlashes - Whether to allow forward slashes (for browser version strings) * @returns {string} The validated name (unchanged if valid, spaces preserved) * @throws {Error} If name contains dangerous patterns * * @example * sanitizeScreenshotName("VBtn dark") // Returns "VBtn dark" (spaces preserved) * sanitizeScreenshotName("My/Component") // Throws error (contains /) */ 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, spaces, and optionally slashes // Spaces are allowed here and will be converted to hyphens in generateBaselineFilename() to match cloud behavior // Replace other characters with underscores const 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 const resolvedWorkingDir = resolve(normalize(workingDir)); const 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 const 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; }); const 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 {}; } const 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") const 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') { const 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 (const [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' && !Number.isNaN(value) && Number.isFinite(value)) { validated[key] = value; } else if (typeof value === 'boolean') { validated[key] = value; } } } return validated; }