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