import {Request} from '@loopback/rest'; import {DEFAULT_VALIDATION_CONFIG} from '../constants/security-defaults'; import {ValidationConfig} from '../types'; import {SecurityError} from '../utils/security-error'; import {SecurityLogger} from '../utils/security-logger'; /** * Input validator that sanitizes and validates incoming request data */ export class InputValidator { private readonly maxBodySizeBytes: number; private skipFields: string[]; constructor( private config?: ValidationConfig, private logger?: SecurityLogger, skipFields?: string[] ) { // Set default configuration this.config = { ...DEFAULT_VALIDATION_CONFIG, ...config }; // Set skip fields this.skipFields = skipFields ?? []; // Convert max body size to bytes this.maxBodySizeBytes = this.parseSize(this.config.maxBodySize ?? '10mb'); this.logger?.logSecurityEvent({}, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'InputValidator', config: { sanitizeInput: this.config.sanitizeInput, maxBodySizeBytes: this.maxBodySizeBytes, maxParameterLength: this.config.maxParameterLength, validateEmails: this.config.validateEmails, validateUrls: this.config.validateUrls, validatePhoneNumbers: this.config.validatePhoneNumbers, strictMode: this.config.strictMode, skipFields: this.skipFields } } }, 'InputValidator', 'constructor'); } /** * Validate and sanitize input from request */ async validateAndSanitizeInput(req: Request): Promise { try { // Check overall body size await this.validateBodySize(req); // Validate and sanitize query parameters if (req.query) { await this.processObject(req.query, 'query parameters', req); } // Validate and sanitize request body if (req.body) { await this.processObject(req.body, 'request body', req); } // Validate and sanitize URL parameters if (req.params) { await this.processObject(req.params, 'URL parameters', req); } // Validate parameter lengths await this.validateParameterLengths(req); this.logger?.logSecurityEvent(req, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'InputValidator', action: 'VALIDATION_COMPLETED' } }); } catch (error) { if (error instanceof SecurityError) { throw error; } this.logger?.logSecurityViolation(req, error instanceof Error ? error : String(error), { component: 'InputValidator', method: 'validateAndSanitizeInput' }, 'InputValidator', 'validateAndSanitizeInput'); throw SecurityError.fromError(error); } } /** * Validate body size */ private async validateBodySize(req: Request): Promise { if (!req.body) { return; } let bodySize: number; if (typeof req.body === 'string') { bodySize = Buffer.byteLength(req.body, 'utf8'); } else { bodySize = Buffer.byteLength(JSON.stringify(req.body), 'utf8'); } if (bodySize > this.maxBodySizeBytes) { this.logger?.logSecurityViolation(req, 'Request body too large', { bodySize, maxAllowed: this.maxBodySizeBytes, maxBodySize: this.config?.maxBodySize }); throw SecurityError.validationFailed( 'request body', `Body size ${this.formatBytes(bodySize)} exceeds maximum of ${this.config?.maxBodySize}`, { bodySize, maxAllowed: this.maxBodySizeBytes } ); } } /** * Check if a field should be skipped from validation */ private shouldSkipField(fieldName: string): boolean { if (!this.skipFields || this.skipFields.length === 0) { return false; } const lowerFieldName = fieldName.toLowerCase(); return this.skipFields.some(skipField => lowerFieldName.includes(skipField.toLowerCase()) ); } /** * Process object recursively for validation and sanitization */ private async processObject( obj: Record, location: string, req: Request, path = '' ): Promise { if (!obj || typeof obj !== 'object') { return; } for (const [key, value] of Object.entries(obj)) { const currentPath = path ? `${path}.${key}` : key; // Skip validation for fields that are in the skip list if (this.shouldSkipField(key)) { this.logger?.logSecurityEvent(req, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'InputValidator', action: 'FIELD_SKIPPED', fieldName: key, location: `${location}.${currentPath}`, reason: 'Field in skip list' } }); continue; } if (typeof value === 'string') { await this.processStringValue(obj, key, value, `${location}.${currentPath}`, req); } else if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { if (typeof value[i] === 'string') { await this.processStringValue( value as unknown as Record, i.toString(), value[i] as string, `${location}.${currentPath}[${i}]`, req ); } else if (typeof value[i] === 'object' && value[i] !== null) { await this.processObject( value[i] as Record, location, req, `${currentPath}[${i}]` ); } } } else if (typeof value === 'object' && value !== null) { await this.processObject( value as Record, location, req, currentPath ); } } } /** * Process individual string value */ private async processStringValue( container: Record, key: string, value: string, location: string, req: Request ): Promise { let processedValue = value; // Check for suspicious patterns BEFORE sanitization to avoid false positives from HTML entities if (this.config?.strictMode) { await this.checkForSuspiciousPatterns(key, value, location, req); } // Sanitize input if configured if (this.config?.sanitizeInput) { processedValue = this.sanitizeString(processedValue, key); } // Validate specific field types (on sanitized value for consistency) await this.validateFieldType(key, processedValue, location, req); // Update the value in place if it was sanitized if (processedValue !== value && this.config?.sanitizeInput) { container[key] = processedValue; this.logger?.logSecurityEvent(req, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'InputValidator', action: 'INPUT_SANITIZED', location, originalLength: value.length, sanitizedLength: processedValue.length } }); } } /** * Check if field should preserve forward slashes (like S3 keys, file paths) */ private shouldPreserveSlashes(fieldName: string): boolean { const preserveSlashFields = ['key', 'path', 'filepath', 'filename', 'url', 'uri', 's3key', ...(this.config?.preserveSlashFields ?? [])]; return preserveSlashFields.some(field => fieldName.toLowerCase().includes(field)); } /** * Sanitize string input */ private sanitizeString(value: string, fieldName?: string): string { // Basic HTML entity encoding let sanitized = value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); // Only encode forward slashes for non-path fields if (!fieldName || !this.shouldPreserveSlashes(fieldName)) { sanitized = sanitized.replace(/\//g, '/'); } // Remove null bytes sanitized = sanitized.replace(/\0/g, ''); // Normalize whitespace in strict mode if (this.config?.strictMode) { sanitized = sanitized.replace(/\s+/g, ' ').trim(); } return sanitized; } /** * Validate field based on its type/name */ private async validateFieldType( fieldName: string, value: string, location: string, req: Request ): Promise { const lowerFieldName = fieldName.toLowerCase(); // Email validation if (this.config?.validateEmails && this.isEmailField(lowerFieldName)) { if (!this.isValidEmail(value)) { this.logger?.logSecurityViolation(req, 'Invalid email format', { field: fieldName, location, value: this.sanitizeLogValue(value) }); throw SecurityError.validationFailed( fieldName, 'Invalid email format', {location, field: fieldName} ); } } // URL validation if (this.config?.validateUrls && this.isUrlField(lowerFieldName)) { if (!this.isValidUrl(value)) { this.logger?.logSecurityViolation(req, 'Invalid URL format', { field: fieldName, location, value: this.sanitizeLogValue(value) }); throw SecurityError.validationFailed( fieldName, 'Invalid URL format', {location, field: fieldName} ); } } // Phone number validation if (this.config?.validatePhoneNumbers && this.isPhoneField(lowerFieldName)) { if (!this.isValidPhoneNumber(value)) { this.logger?.logSecurityViolation(req, 'Invalid phone number format', { field: fieldName, location, value: this.sanitizeLogValue(value) }, 'InputValidator', 'validateFieldValue'); throw SecurityError.validationFailed( fieldName, 'Invalid phone number format', {location, field: fieldName} ); } } // CSV injection validation if (this.config?.detectCsvInjection) { if (this.containsCsvInjectionPattern(value, lowerFieldName)) { this.logger?.logSecurityViolation(req, 'CSV injection pattern detected', { field: fieldName, location, value: this.sanitizeLogValue(value), pattern: this.getCsvInjectionPattern(value) }, 'InputValidator', 'validateFieldType'); throw SecurityError.csvInjectionDetected( location, this.getCsvInjectionPattern(value), { field: fieldName, detectedValue: this.sanitizeLogValue(value) } ); } } // Note: Suspicious pattern checks are now done before sanitization in processStringValue } /** * Check if field name indicates email */ private isEmailField(fieldName: string): boolean { return ['email', 'mail', 'e-mail'].some(pattern => fieldName.includes(pattern)); } /** * Check if field name indicates URL */ private isUrlField(fieldName: string): boolean { return ['url', 'link', 'href', 'uri', 'website'].some(pattern => fieldName.includes(pattern)); } /** * Check if field name indicates phone number */ public isPhoneField(fieldName: string): boolean { return ['phone', 'mobile', 'tel', 'telephone', 'cell', 'contact'].some(pattern => fieldName.includes(pattern)); } /** * Validate email format */ private isValidEmail(email: string): boolean { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email) && email.length <= 320; // RFC 5321 limit } /** * Validate URL format */ private isValidUrl(url: string): boolean { try { const parsedUrl = new URL(url); // Only allow HTTP(S) protocols by default return ['http:', 'https:'].includes(parsedUrl.protocol); } catch { return false; } } /** * Validate phone number format * Supports multiple international formats */ public isValidPhoneNumber(phone?: string): boolean { if (!phone) return true; // Remove all whitespace, dashes, dots, and parentheses for validation const cleanPhone = phone.replace(/[\s\-\.\(\)]/g, ''); // Phone number validation patterns const phonePatterns = [ // International format with country code (+1234567890) /^\+[1-9]\d{1,14}$/, // US/Canada format (1234567890, 123-456-7890, (123) 456-7890, etc.) /^(\+?1[-.\s]?)?\(?[2-9][0-8][0-9]\)?[-.\s]?[2-9][0-9][0-9][-.\s]?[0-9]{4}$/, // UK format (+44 or 0) /^(\+44\s?[0-9]{2,5}\s?[0-9]{4,6}|(0[0-9]{2,4}\s?[0-9]{4,7}))$/, // Generic international format (7-15 digits with optional + prefix) /^(\+\d{1,3}\s?)?\d{7,15}$/, // European formats (various country codes) /^(\+\d{1,3}[-.\s]?)?\d{7,14}$/ ]; // Check if the original phone or cleaned phone matches any pattern return phonePatterns.some(pattern => pattern.test(phone) || pattern.test(cleanPhone) ) && cleanPhone.length >= 7 && cleanPhone.length <= 18; // Length bounds check } /** * Check for suspicious patterns */ private async checkForSuspiciousPatterns( fieldName: string, value: string, location: string, req: Request ): Promise { // Skip entirely if configured to do so if (this.config?.skipSuspiciousPatterns) { return; } // Skip validation for common legitimate patterns const whitelistedPatterns = [ // Date/time patterns /^\d{4}-\d{2}-\d{2}[\s\+]\d{2}:\d{2}:\d{2}$/, // YYYY-MM-DD HH:MM:SS or YYYY-MM-DD+HH:MM:SS /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/, // ISO date format /^\d{4}-\d{2}-\d{2}$/, // YYYY-MM-DD /^\d{2}:\d{2}:\d{2}$/, // HH:MM:SS // Common API sorting patterns /^[a-zA-Z_][a-zA-Z0-9_]*\/(asc|desc)$/i, // fieldName/asc or fieldName/desc /^[a-zA-Z_][a-zA-Z0-9_]*/(asc|desc)$/i, // HTML entity encoded version // Common boolean values /^(true|false)$/i, // Numbers (including negative and decimals) /^-?\d+(\.\d+)?$/, // Array notation (for query params like ENV[]) /^[a-zA-Z_][a-zA-Z0-9_]*\[\]$/, // Common status/enum values /^[A-Z_]+$/, ...(this.config?.whitelistedPatterns ?? []) // Add user-defined whitelisted patterns ]; // Check if value matches whitelisted patterns for (const pattern of whitelistedPatterns) { if (pattern.test(value)) { return; // Skip suspicious pattern check for whitelisted formats } } const suspiciousPatterns = [ // Path traversal /\.\.(\/|\\)/g, /\.\.\\/g, // Command injection patterns (more specific to avoid false positives) /[;&|`]/g, // Removed () from this pattern since they're used in valid contexts // File inclusion patterns /(file|php|data):/gi, // Potential code patterns /(eval|exec|system|shell_exec|passthru)/gi ]; for (const pattern of suspiciousPatterns) { if (pattern.test(value)) { this.logger?.logSecurityViolation(req, 'Suspicious pattern detected', { field: fieldName, location, pattern: pattern.toString(), value: this.sanitizeLogValue(value) }); throw SecurityError.validationFailed( fieldName, 'Input contains suspicious patterns', { location, field: fieldName, pattern: pattern.toString() } ); } } } /** * Validate parameter lengths */ private async validateParameterLengths(req: Request): Promise { // skip until this is set to true if (!this.config?.validateParamLength) { return; } const maxLength = this.config?.maxParameterLength ?? 1000; const checkObjectLengths = (obj: Record, objName: string) => { for (const [key, value] of Object.entries(obj)) { if (typeof value === 'string' && value.length > maxLength) { this.logger?.logSecurityViolation(req, 'Parameter exceeds maximum length', { parameter: key, location: objName, length: value.length, maxLength }); throw SecurityError.validationFailed( key, `Parameter length ${value.length} exceeds maximum of ${maxLength}`, { location: objName, length: value.length, maxLength } ); } } }; if (req.query) { checkObjectLengths(req.query as Record, 'query parameters'); } if (req.params) { checkObjectLengths(req.params, 'URL parameters'); } if (req.body && typeof req.body === 'object') { this.checkObjectLengthsRecursive(req.body, 'request body', maxLength, req); } } /** * Recursively check object lengths */ private checkObjectLengthsRecursive( obj: Record, location: string, maxLength: number, req: Request, path = '' ): void { for (const [key, value] of Object.entries(obj)) { const currentPath = path ? `${path}.${key}` : key; if (typeof value === 'string' && value.length > maxLength) { throw SecurityError.validationFailed( currentPath, `Parameter length ${value.length} exceeds maximum of ${maxLength}`, { location, length: value.length, maxLength } ); } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { this.checkObjectLengthsRecursive( value as Record, location, maxLength, req, currentPath ); } } } /** * Parse size string to bytes */ private parseSize(sizeStr: string): number { const units: Record = { b: 1, kb: 1024, mb: 1024 * 1024, gb: 1024 * 1024 * 1024 }; const match = sizeStr.toLowerCase().match(/^(\d+)(b|kb|mb|gb)$/); if (!match) { return 10 * 1024 * 1024; // Default 10MB } return parseInt(match[1], 10) * units[match[2]]; } /** * Format bytes for human readable display */ private formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`; } /** * Sanitize value for logging */ private sanitizeLogValue(value: string): string { if (value.length > 100) { return `${value.substring(0, 100)}...`; } // Replace control characters by checking char codes return value.split('').map(char => { const code = char.charCodeAt(0); return (code < 32 || code === 127) ? '?' : char; }).join(''); } /** * Check if value contains CSV injection patterns */ private containsCsvInjectionPattern(value: string, fieldName: string): boolean { // Skip validation for whitelisted phone number fields if (this.isWhitelistedCsvField(fieldName)) { return false; } // Check for CSV injection patterns const csvInjectionPattern = /^[@=+\t]/; return csvInjectionPattern.test(value); } /** * Get the CSV injection pattern from the value */ private getCsvInjectionPattern(value: string): string { const csvInjectionPattern = /^[@=+\t]/; const match = value.match(csvInjectionPattern); return match ? match[0] : 'unknown'; } /** * Check if field is whitelisted for CSV injection (typically phone fields) */ private isWhitelistedCsvField(fieldName: string): boolean { const whitelistedFields = this.config?.csvInjectionWhitelistedFields ?? []; // Check against configured whitelist if (whitelistedFields.some(field => fieldName.includes(field.toLowerCase()))) { return true; } // Additional check for phone number fields return this.isPhoneField(fieldName); } /** * Get validation statistics */ getStats(): { maxBodySizeBytes: number; maxParameterLength: number; sanitizeInput: boolean; strictMode: boolean; validateEmails: boolean; validateUrls: boolean; } { return { maxBodySizeBytes: this.maxBodySizeBytes, maxParameterLength: this.config?.maxParameterLength ?? 1000, sanitizeInput: this.config?.sanitizeInput ?? true, strictMode: this.config?.strictMode ?? false, validateEmails: this.config?.validateEmails ?? true, validateUrls: this.config?.validateUrls ?? true }; } /** * Update configuration */ updateConfig(config: Partial): void { this.config = {...this.config, ...config}; } }