/** * Security Utilities for Input Validation and XSS Prevention * * Provides comprehensive input validation, sanitization, and XSS prevention * for the ZCLAW application. * * Security features: * - HTML sanitization * - URL validation * - Path traversal prevention * - Input validation helpers * - Content Security Policy helpers */ import { createLogger } from './logger'; const logger = createLogger('SecurityUtils'); // ============================================================================ // HTML Sanitization // ============================================================================ /** * HTML entity encoding map */ const HTML_ENTITIES: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', '`': '`', '=': '=', }; /** * Escape HTML entities in a string * Prevents XSS attacks by encoding dangerous characters * * @param input - The string to escape * @returns The escaped string */ export function escapeHtml(input: string): string { if (typeof input !== 'string') { return ''; } return input.replace(/[&<>"'`=/]/g, (char) => HTML_ENTITIES[char] || char); } /** * Unescape HTML entities in a string * * @param input - The string to unescape * @returns The unescaped string */ export function unescapeHtml(input: string): string { if (typeof input !== 'string') { return ''; } const textarea = document.createElement('textarea'); textarea.innerHTML = input; return textarea.value; } /** * Allowed HTML tags for safe rendering */ const ALLOWED_TAGS = new Set([ 'p', 'br', 'b', 'i', 'u', 'strong', 'em', 'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'a', 'span', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', ]); /** * Allowed HTML attributes */ const ALLOWED_ATTRIBUTES = new Set([ 'href', 'title', 'class', 'id', 'target', 'rel', ]); /** * Sanitize HTML content for safe rendering * Removes dangerous tags and attributes while preserving safe content * * @param html - The HTML string to sanitize * @param options - Sanitization options * @returns The sanitized HTML */ export function sanitizeHtml( html: string, options: { allowedTags?: string[]; allowedAttributes?: string[]; allowDataAttributes?: boolean; } = {} ): string { if (typeof html !== 'string') { return ''; } const allowedTags = new Set(options.allowedTags || ALLOWED_TAGS); const allowedAttributes = new Set(options.allowedAttributes || ALLOWED_ATTRIBUTES); // Create a temporary container const container = document.createElement('div'); container.innerHTML = html; // Recursively clean elements function cleanElement(element: Element): void { // Remove script tags entirely if (element.tagName.toLowerCase() === 'script') { element.remove(); return; } // Remove style tags entirely if (element.tagName.toLowerCase() === 'style') { element.remove(); return; } // Remove event handlers and dangerous attributes const attributes = Array.from(element.attributes); for (const attr of attributes) { const attrName = attr.name.toLowerCase(); // Remove event handlers (onclick, onload, etc.) if (attrName.startsWith('on')) { element.removeAttribute(attr.name); continue; } // Remove javascript: URLs if (attrName === 'href' || attrName === 'src') { const value = attr.value.toLowerCase().trim(); if (value.startsWith('javascript:') || value.startsWith('data:text/html')) { element.removeAttribute(attr.name); continue; } } // Remove data attributes if not allowed if (attrName.startsWith('data-') && !options.allowDataAttributes) { element.removeAttribute(attr.name); continue; } // Remove non-allowed attributes if (!allowedAttributes.has(attrName)) { element.removeAttribute(attr.name); } } // Remove non-allowed tags (but keep their content) if (!allowedTags.has(element.tagName.toLowerCase())) { const parent = element.parentNode; while (element.firstChild) { parent?.insertBefore(element.firstChild, element); } parent?.removeChild(element); return; } // Recursively clean child elements Array.from(element.children).forEach(cleanElement); } // Clean all elements Array.from(container.children).forEach(cleanElement); return container.innerHTML; } // ============================================================================ // URL Validation // ============================================================================ /** * Allowed URL schemes */ const ALLOWED_SCHEMES = new Set([ 'http', 'https', 'mailto', 'tel', 'ftp', 'file', ]); /** * Validate and sanitize a URL * * @param url - The URL to validate * @param options - Validation options * @returns The validated URL or null if invalid */ export function validateUrl( url: string, options: { allowedSchemes?: string[]; allowLocalhost?: boolean; allowPrivateIp?: boolean; maxLength?: number; } = {} ): string | null { if (typeof url !== 'string' || url.length === 0) { return null; } const maxLength = options.maxLength || 2048; if (url.length > maxLength) { return null; } try { const parsed = new URL(url); // Check scheme const allowedSchemes = new Set(options.allowedSchemes || ALLOWED_SCHEMES); if (!allowedSchemes.has(parsed.protocol.replace(':', ''))) { return null; } // Check for localhost if (!options.allowLocalhost) { if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]') { return null; } } // Check for private IP ranges if (!options.allowPrivateIp) { const privateIpRegex = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/; if (privateIpRegex.test(parsed.hostname)) { return null; } } return parsed.toString(); } catch (e) { logger.debug('URL validation failed', { error: e }); return null; } } /** * Check if a URL is safe for redirect * Prevents open redirect vulnerabilities * * @param url - The URL to check * @returns True if the URL is safe for redirect */ export function isSafeRedirectUrl(url: string): boolean { if (typeof url !== 'string' || url.length === 0) { return false; } // Relative URLs are generally safe if (url.startsWith('/') && !url.startsWith('//')) { return true; } // Check for javascript: protocol const lowerUrl = url.toLowerCase().trim(); if (lowerUrl.startsWith('javascript:')) { return false; } // Check for data: protocol if (lowerUrl.startsWith('data:')) { return false; } // Validate as absolute URL const validated = validateUrl(url, { allowLocalhost: false }); return validated !== null; } // ============================================================================ // Path Validation // ============================================================================ /** * Validate a file path to prevent path traversal attacks * * @param path - The path to validate * @param options - Validation options * @returns The validated path or null if invalid */ export function validatePath( path: string, options: { allowAbsolute?: boolean; allowParentDir?: boolean; maxLength?: number; allowedExtensions?: string[]; baseDir?: string; } = {} ): string | null { if (typeof path !== 'string' || path.length === 0) { return null; } const maxLength = options.maxLength || 4096; if (path.length > maxLength) { return null; } // Normalize path separators let normalized = path.replace(/\\/g, '/'); // Check for null bytes if (normalized.includes('\0')) { return null; } // Check for path traversal if (!options.allowParentDir) { if (normalized.includes('..') || normalized.includes('./')) { return null; } } // Check for absolute paths if (!options.allowAbsolute) { if (normalized.startsWith('/') || /^[a-zA-Z]:/.test(normalized)) { return null; } } // Check extensions if (options.allowedExtensions && options.allowedExtensions.length > 0) { const ext = normalized.split('.').pop()?.toLowerCase(); if (!ext || !options.allowedExtensions.includes(ext)) { return null; } } // If baseDir is specified, ensure path is within it if (options.baseDir) { const baseDir = options.baseDir.replace(/\\/g, '/').replace(/\/$/, ''); if (!normalized.startsWith(baseDir)) { // Try to resolve relative to baseDir try { const resolved = new URL(normalized, `file://${baseDir}/`).pathname; if (!resolved.startsWith(baseDir)) { return null; } normalized = resolved; } catch (e) { logger.debug('Path resolution failed', { error: e }); return null; } } } return normalized; } // ============================================================================ // Input Validation Helpers // ============================================================================ /** * Validate an email address * * @param email - The email to validate * @returns True if valid */ export function isValidEmail(email: string): boolean { if (typeof email !== 'string' || email.length === 0 || email.length > 254) { return false; } // RFC 5322 compliant regex (simplified) const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[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])?)*$/; return emailRegex.test(email); } /** * Validate a username * * @param username - The username to validate * @param options - Validation options * @returns True if valid */ export function isValidUsername( username: string, options: { minLength?: number; maxLength?: number; allowedChars?: RegExp; } = {} ): boolean { const minLength = options.minLength || 3; const maxLength = options.maxLength || 30; const allowedChars = options.allowedChars || /^[a-zA-Z0-9_-]+$/; if (typeof username !== 'string') { return false; } if (username.length < minLength || username.length > maxLength) { return false; } return allowedChars.test(username); } /** * Validate a password strength * * @param password - The password to validate * @param options - Validation options * @returns Validation result with strength score */ export function validatePasswordStrength( password: string, options: { minLength?: number; requireUppercase?: boolean; requireLowercase?: boolean; requireNumber?: boolean; requireSpecial?: boolean; maxLength?: number; } = {} ): { valid: boolean; score: number; issues: string[]; } { const minLength = options.minLength || 8; const maxLength = options.maxLength || 128; const issues: string[] = []; let score = 0; if (typeof password !== 'string') { return { valid: false, score: 0, issues: ['Password must be a string'] }; } if (password.length < minLength) { issues.push(`Password must be at least ${minLength} characters`); } else { score += Math.min(password.length / 8, 3) * 10; } if (password.length > maxLength) { issues.push(`Password must be at most ${maxLength} characters`); } if (options.requireUppercase !== false && !/[A-Z]/.test(password)) { issues.push('Password must contain an uppercase letter'); } else if (/[A-Z]/.test(password)) { score += 10; } if (options.requireLowercase !== false && !/[a-z]/.test(password)) { issues.push('Password must contain a lowercase letter'); } else if (/[a-z]/.test(password)) { score += 10; } if (options.requireNumber !== false && !/[0-9]/.test(password)) { issues.push('Password must contain a number'); } else if (/[0-9]/.test(password)) { score += 10; } if (options.requireSpecial !== false && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { issues.push('Password must contain a special character'); } else if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { score += 15; } // Check for common patterns const commonPatterns = [ /123/, /abc/i, /qwe/i, /password/i, /admin/i, /letmein/i, ]; for (const pattern of commonPatterns) { if (pattern.test(password)) { issues.push('Password contains a common pattern'); score -= 10; break; } } return { valid: issues.length === 0, score: Math.max(0, Math.min(100, score)), issues, }; } /** * Sanitize a filename * * @param filename - The filename to sanitize * @returns The sanitized filename */ export function sanitizeFilename(filename: string): string { if (typeof filename !== 'string') { return ''; } // Remove path separators let sanitized = filename.replace(/[/\\]/g, '_'); // Remove null bytes sanitized = sanitized.replace(/\0/g, ''); // Remove control characters // eslint-disable-next-line no-control-regex sanitized = sanitized.replace(/[\x00-\x1f\x7f]/g, ''); // Remove dangerous characters sanitized = sanitized.replace(/[<>:"|?*]/g, '_'); // Trim whitespace and dots sanitized = sanitized.trim().replace(/^\.+|\.+$/g, ''); // Limit length if (sanitized.length > 255) { const ext = sanitized.split('.').pop(); const name = sanitized.slice(0, -(ext?.length || 0) - 1); sanitized = name.slice(0, 250 - (ext?.length || 0)) + (ext ? `.${ext}` : ''); } return sanitized; } /** * Sanitize JSON input * Prevents prototype pollution and other JSON-based attacks * * @param json - The JSON string to sanitize * @returns The parsed and sanitized object or null if invalid */ export function sanitizeJson(json: string): T | null { if (typeof json !== 'string') { return null; } try { const parsed = JSON.parse(json); // Check for prototype pollution if (typeof parsed === 'object' && parsed !== null) { const dangerousKeys = ['__proto__', 'constructor', 'prototype']; for (const key of dangerousKeys) { if (key in parsed) { delete (parsed as Record)[key]; } } } return parsed as T; } catch (e) { logger.debug('JSON sanitize parse failed', { error: e }); return null; } } // ============================================================================ // Rate Limiting // ============================================================================ interface RateLimitEntry { count: number; resetAt: number; } const rateLimitStore = new Map(); /** * Check if an action is rate limited * * @param key - The rate limit key (e.g., 'api:username') * @param maxAttempts - Maximum attempts allowed * @param windowMs - Time window in milliseconds * @returns True if rate limited (should block), false otherwise */ export function isRateLimited( key: string, maxAttempts: number, windowMs: number ): boolean { const now = Date.now(); const entry = rateLimitStore.get(key); if (!entry || now > entry.resetAt) { rateLimitStore.set(key, { count: 1, resetAt: now + windowMs, }); return false; } if (entry.count >= maxAttempts) { return true; } entry.count++; return false; } /** * Reset rate limit for a key * * @param key - The rate limit key to reset */ export function resetRateLimit(key: string): void { rateLimitStore.delete(key); } /** * Get remaining attempts for a rate-limited action * * @param key - The rate limit key * @param maxAttempts - Maximum attempts allowed * @returns Number of remaining attempts */ export function getRemainingAttempts(key: string, maxAttempts: number): number { const entry = rateLimitStore.get(key); if (!entry || Date.now() > entry.resetAt) { return maxAttempts; } return Math.max(0, maxAttempts - entry.count); } // ============================================================================ // Content Security Policy Helpers // ============================================================================ /** * Generate a nonce for CSP * * @returns A base64-encoded nonce */ export function generateCspNonce(): string { const array = crypto.getRandomValues(new Uint8Array(16)); return btoa(String.fromCharCode(...array)); } /** * CSP directives for secure applications */ export const DEFAULT_CSP_DIRECTIVES = { 'default-src': "'self'", 'script-src': "'self' 'unsafe-inline'", // Note: unsafe-inline should be avoided in production 'style-src': "'self' 'unsafe-inline'", 'img-src': "'self' data: https:", 'font-src': "'self'", 'connect-src': "'self' ws: wss:", 'frame-ancestors': "'none'", 'base-uri': "'self'", 'form-action': "'self'", }; /** * Build a Content Security Policy header value * * @param directives - CSP directives * @returns The CSP header value */ export function buildCspHeader( directives: Partial = DEFAULT_CSP_DIRECTIVES ): string { const merged = { ...DEFAULT_CSP_DIRECTIVES, ...directives }; return Object.entries(merged) .map(([key, value]) => `${key} ${value}`) .join('; '); } // ============================================================================ // Security Headers Validation // ============================================================================ /** * Check if security headers are properly set (for browser environments) */ export function checkSecurityHeaders(): { secure: boolean; issues: string[]; } { const issues: string[] = []; // Check if running over HTTPS if (typeof window !== 'undefined') { if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost') { issues.push('Application is not running over HTTPS'); } // Check for mixed content if (window.location.protocol === 'https:') { // This would require DOM inspection to detect mixed content } } return { secure: issues.length === 0, issues, }; } // ============================================================================ // Secure Random Generation // ============================================================================ /** * Generate a secure random token * * @param length - Token length in bytes * @returns Hex-encoded random token */ export function generateSecureToken(length: number = 32): string { const array = crypto.getRandomValues(new Uint8Array(length)); return Array.from(array) .map(b => b.toString(16).padStart(2, '0')) .join(''); } /** * Generate a secure random ID * * @param prefix - Optional prefix * @returns A secure random ID */ export function generateSecureId(prefix: string = ''): string { const timestamp = Date.now().toString(36); const random = generateSecureToken(8); return prefix ? `${prefix}_${timestamp}_${random}` : `${timestamp}_${random}`; }