UNPKG

create-roadkit

Version:

Beautiful Next.js roadmap website generator with full-screen kanban boards, dark/light mode, and static export

492 lines (443 loc) 14.7 kB
/** * Security utilities for RoadKit CLI * * This module provides critical security functions for validating user inputs, * sanitizing file paths, and preventing common security vulnerabilities like * path traversal attacks, code injection, and other malicious inputs. */ import { resolve, normalize, relative, isAbsolute, sep } from 'path'; import { z } from 'zod'; /** * Security configuration and limits */ const SECURITY_CONFIG = { /** Maximum allowed path depth to prevent deep directory traversal */ maxPathDepth: 10, /** Maximum file name length to prevent buffer overflow attacks */ maxFileNameLength: 255, /** Maximum project name length */ maxProjectNameLength: 50, /** Allowed characters in project names (alphanumeric, hyphens, underscores) */ projectNamePattern: /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, /** Dangerous path segments that should never be allowed as project names */ dangerousProjectNames: [ 'node_modules', '.git', '.env', 'etc', 'usr', 'var', 'tmp', 'proc', 'sys', 'root' ], /** Dangerous path segments in file paths */ dangerousPathSegments: [ '..', '.', ], /** File extensions that should never be processed for variable replacement */ binaryExtensions: [ '.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.ico', '.mp3', '.mp4', '.avi', '.mov', '.zip', '.tar', '.gz', '.7z', '.rar', '.pdf', '.doc', '.docx' ] } as const; /** * Result of path validation */ interface PathValidationResult { /** Whether the path is valid and safe */ isValid: boolean; /** Sanitized version of the path (only if valid) */ sanitizedPath?: string; /** Specific error message if invalid */ error?: string; /** Warning messages for potentially risky but allowed operations */ warnings?: string[]; } /** * Result of input validation */ interface ValidationResult<T> { /** Whether the input is valid */ isValid: boolean; /** Validated and sanitized data (only if valid) */ data?: T; /** Specific error message if invalid */ error?: string; /** Warning messages for edge cases */ warnings?: string[]; } /** * Zod schema for project names with security constraints */ const projectNameSchema = z.string() .min(1, 'Project name cannot be empty') .max(SECURITY_CONFIG.maxProjectNameLength, `Project name cannot exceed ${SECURITY_CONFIG.maxProjectNameLength} characters`) .regex(SECURITY_CONFIG.projectNamePattern, 'Project name must contain only alphanumeric characters, periods, hyphens, and underscores, and cannot start with a special character') .refine(name => !SECURITY_CONFIG.dangerousProjectNames.includes(name.toLowerCase()), 'Project name cannot be a reserved system directory name'); /** * Zod schema for template types */ const templateTypeSchema = z.enum(['basic', 'advanced', 'enterprise', 'custom']); /** * Zod schema for theme types */ const themeTypeSchema = z.enum(['modern', 'classic', 'minimal', 'corporate']); /** * Zod schema for file paths */ const filePathSchema = z.string() .min(1, 'Path cannot be empty') .max(4096, 'Path cannot exceed 4096 characters') .refine(path => !path.includes('\0'), 'Path cannot contain null bytes') .refine(path => !path.match(/[<>:"|?*]/), 'Path contains invalid characters'); /** * Validates and sanitizes a project name * * @param projectName - The project name to validate * @returns Validation result with sanitized name if valid */ export function validateProjectName(projectName: string): ValidationResult<string> { try { const validatedName = projectNameSchema.parse(projectName.trim()); const warnings: string[] = []; // Check for potentially confusing names if (validatedName.startsWith('.')) { warnings.push('Project name starts with a dot, which may create hidden directories on Unix systems'); } if (validatedName.includes('--')) { warnings.push('Project name contains consecutive hyphens, which may cause issues with some tools'); } return { isValid: true, data: validatedName, warnings: warnings.length > 0 ? warnings : undefined }; } catch (error) { return { isValid: false, error: error instanceof z.ZodError ? error.errors[0]?.message || 'Invalid project name' : 'Failed to validate project name' }; } } /** * Validates a template type * * @param template - The template type to validate * @returns Validation result */ export function validateTemplateType(template: string): ValidationResult<string> { try { const validatedTemplate = templateTypeSchema.parse(template); return { isValid: true, data: validatedTemplate }; } catch (error) { return { isValid: false, error: error instanceof z.ZodError ? `Invalid template type. Must be one of: ${templateTypeSchema.options.join(', ')}` : 'Failed to validate template type' }; } } /** * Validates a theme type * * @param theme - The theme type to validate * @returns Validation result */ export function validateThemeType(theme: string): ValidationResult<string> { try { const validatedTheme = themeTypeSchema.parse(theme); return { isValid: true, data: validatedTheme }; } catch (error) { return { isValid: false, error: error instanceof z.ZodError ? `Invalid theme type. Must be one of: ${themeTypeSchema.options.join(', ')}` : 'Failed to validate theme type' }; } } /** * Sanitizes and validates a file path to prevent directory traversal attacks * * This function performs comprehensive path validation to prevent: * - Directory traversal attacks (../, ..\) * - Null byte injection * - Overly long paths * - Paths to system directories * - Invalid characters * * @param inputPath - The path to validate and sanitize * @param basePath - Optional base path to restrict operations to * @returns Validation result with sanitized path if valid */ export function sanitizePath(inputPath: string, basePath?: string): PathValidationResult { try { // Basic validation using Zod schema filePathSchema.parse(inputPath); } catch (error) { return { isValid: false, error: error instanceof z.ZodError ? error.errors[0]?.message || 'Invalid path format' : 'Path validation failed' }; } // Normalize the path to resolve any . or .. segments const normalizedPath = normalize(inputPath); // Check for path traversal attempts if (normalizedPath.includes('..')) { return { isValid: false, error: 'Path contains directory traversal sequences (..)' }; } // Split path into segments for detailed analysis const pathSegments = normalizedPath.split(sep).filter(segment => segment !== ''); // Check path depth if (pathSegments.length > SECURITY_CONFIG.maxPathDepth) { return { isValid: false, error: `Path depth exceeds maximum allowed depth of ${SECURITY_CONFIG.maxPathDepth}` }; } // Check for dangerous path segments (only check for traversal attempts, not regular directories) const dangerousSegments = pathSegments.filter(segment => SECURITY_CONFIG.dangerousPathSegments.includes(segment) || (segment === '..' || segment === '.') ); if (dangerousSegments.length > 0) { return { isValid: false, error: `Path contains directory traversal sequences: ${dangerousSegments.join(', ')}` }; } // Check individual segment lengths for (const segment of pathSegments) { if (segment.length > SECURITY_CONFIG.maxFileNameLength) { return { isValid: false, error: `Path segment "${segment}" exceeds maximum length of ${SECURITY_CONFIG.maxFileNameLength} characters` }; } } // If basePath is provided, ensure the resolved path stays within it if (basePath) { const resolvedBasePath = resolve(basePath); const resolvedInputPath = resolve(basePath, normalizedPath); // Check if the resolved path is within the base path const relativePath = relative(resolvedBasePath, resolvedInputPath); if (relativePath.startsWith('..') || isAbsolute(relativePath)) { return { isValid: false, error: 'Path attempts to access files outside the allowed directory' }; } return { isValid: true, sanitizedPath: resolvedInputPath }; } return { isValid: true, sanitizedPath: resolve(normalizedPath) }; } /** * Validates that a file extension is safe for template variable processing * * @param filePath - Path to the file * @returns Whether the file is safe to process for variable replacement */ export function isSafeForVariableReplacement(filePath: string): boolean { const extension = filePath.toLowerCase().split('.').pop(); if (!extension) { return false; // Files without extensions are not processed } return !SECURITY_CONFIG.binaryExtensions.includes(`.${extension}`); } /** * Sanitizes template variable content to prevent code injection * * This function removes or escapes potentially dangerous content that could * be injected into template files, particularly JavaScript/TypeScript code. * * @param variableValue - The variable value to sanitize * @returns Sanitized variable value */ export function sanitizeTemplateVariable(variableValue: string): string { if (typeof variableValue !== 'string') { throw new Error('Template variable value must be a string'); } return variableValue // Remove null bytes .replace(/\0/g, '') // Remove or escape script tags .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '') // Remove potentially dangerous JavaScript constructs .replace(/javascript:/gi, '') // Remove eval and similar dangerous functions .replace(/\b(eval|Function|setTimeout|setInterval)\s*\(/gi, '') // Escape template literals that could break out of string contexts .replace(/`/g, '\\`') .replace(/\$\{/g, '\\$\\{') // Limit length to prevent DoS attacks .substring(0, 1000); } /** * Validates template variable replacement patterns to ensure they're safe * * @param content - Template content to validate * @param variables - Variables that will be replaced * @returns Validation result */ export function validateTemplateContent( content: string, variables: Array<{ name: string; value: string }> ): ValidationResult<string> { const warnings: string[] = []; // Check for potentially dangerous patterns in content const dangerousPatterns = [ /eval\s*\(/gi, /Function\s*\(/gi, /document\.write/gi, /innerHTML\s*=/gi, /outerHTML\s*=/gi, /javascript:/gi, /<script/gi ]; for (const pattern of dangerousPatterns) { if (pattern.test(content)) { warnings.push(`Template content contains potentially dangerous pattern: ${pattern.source}`); } } // Validate variable values for (const variable of variables) { const sanitized = sanitizeTemplateVariable(variable.value); if (sanitized !== variable.value) { warnings.push(`Variable "${variable.name}" was sanitized due to potentially unsafe content`); } } return { isValid: true, data: content, warnings: warnings.length > 0 ? warnings : undefined }; } /** * Creates a secure temporary directory name * * @returns A cryptographically secure random directory name */ export function createSecureTempName(): string { // Use crypto.randomUUID if available, otherwise fall back to timestamp + random if (typeof crypto !== 'undefined' && crypto.randomUUID) { return `roadkit-${crypto.randomUUID()}`; } // Fallback for older environments const timestamp = Date.now(); const randomSuffix = Math.random().toString(36).substring(2, 15); return `roadkit-${timestamp}-${randomSuffix}`; } /** * Validates CLI options for security issues * * @param options - CLI options to validate * @returns Validation result */ export function validateCLIOptions(options: Record<string, any>): ValidationResult<Record<string, any>> { const validatedOptions: Record<string, any> = {}; const warnings: string[] = []; // Validate project name if provided if (options.name) { const nameValidation = validateProjectName(options.name); if (!nameValidation.isValid) { return { isValid: false, error: `Invalid project name: ${nameValidation.error}` }; } validatedOptions.name = nameValidation.data; if (nameValidation.warnings) { warnings.push(...nameValidation.warnings); } } // Validate template if provided if (options.template) { const templateValidation = validateTemplateType(options.template); if (!templateValidation.isValid) { return { isValid: false, error: `Invalid template: ${templateValidation.error}` }; } validatedOptions.template = templateValidation.data; } // Validate theme if provided if (options.theme) { const themeValidation = validateThemeType(options.theme); if (!themeValidation.isValid) { return { isValid: false, error: `Invalid theme: ${themeValidation.error}` }; } validatedOptions.theme = themeValidation.data; } // Validate output path if provided if (options.output) { const pathValidation = sanitizePath(options.output); if (!pathValidation.isValid) { return { isValid: false, error: `Invalid output path: ${pathValidation.error}` }; } validatedOptions.output = pathValidation.sanitizedPath; } // Copy over boolean flags (they're safe) const booleanFlags = ['skipPrompts', 'verbose', 'dryRun', 'overwrite', 'skipInstall', 'skipGit']; for (const flag of booleanFlags) { if (typeof options[flag] === 'boolean') { validatedOptions[flag] = options[flag]; } } return { isValid: true, data: validatedOptions, warnings: warnings.length > 0 ? warnings : undefined }; } /** * Error class for security-related issues */ export class SecurityError extends Error { constructor(message: string, public readonly securityType: string) { super(message); this.name = 'SecurityError'; } } /** * Creates a security error * * @param message - Error message * @param securityType - Type of security issue * @returns SecurityError instance */ export function createSecurityError(message: string, securityType: string): SecurityError { return new SecurityError(message, securityType); }