Add comprehensive input validation and sanitization
Frontend Validation: - Created Validator class with comprehensive validation methods - HTML sanitization to prevent XSS attacks - Text sanitization removing dangerous characters - Language code validation against allowed list - Audio file validation (size, type, extension) - URL validation preventing injection attacks - API key format validation - Request size validation - Filename sanitization - Settings validation with type checking - Cache key sanitization - Client-side rate limiting tracking Backend Validation: - Created validators.py module for server-side validation - Audio file validation with size and type checks - Text sanitization with length limits - Language code validation - URL and API key validation - JSON request size validation - Rate limiting per endpoint (30 req/min) - Added validation to all API endpoints - Error boundary decorators on all routes - CSRF token support ready Security Features: - Prevents XSS through HTML escaping - Prevents SQL injection through input sanitization - Prevents directory traversal in filenames - Prevents oversized requests (DoS protection) - Rate limiting prevents abuse - Type checking prevents type confusion attacks - Length limits prevent memory exhaustion - Character filtering prevents control character injection All user inputs are now validated and sanitized before processing. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
		| @@ -17,6 +17,7 @@ import { | ||||
| import { TranslationCache } from './translationCache'; | ||||
| import { RequestQueueManager } from './requestQueue'; | ||||
| import { ErrorBoundary } from './errorBoundary'; | ||||
| import { Validator } from './validator'; | ||||
|  | ||||
| // Initialize error boundary | ||||
| const errorBoundary = ErrorBoundary.getInstance(); | ||||
| @@ -163,8 +164,26 @@ function initApp(): void { | ||||
|         } | ||||
|          | ||||
|         const updateData: TTSConfigUpdate = {}; | ||||
|         if (newUrl) updateData.server_url = newUrl; | ||||
|         if (newApiKey) updateData.api_key = newApiKey; | ||||
|          | ||||
|         // Validate URL | ||||
|         if (newUrl) { | ||||
|             const validatedUrl = Validator.validateURL(newUrl); | ||||
|             if (!validatedUrl) { | ||||
|                 alert('Invalid server URL. Please enter a valid HTTP/HTTPS URL.'); | ||||
|                 return; | ||||
|             } | ||||
|             updateData.server_url = validatedUrl; | ||||
|         } | ||||
|          | ||||
|         // Validate API key | ||||
|         if (newApiKey) { | ||||
|             const validatedKey = Validator.validateAPIKey(newApiKey); | ||||
|             if (!validatedKey) { | ||||
|                 alert('Invalid API key format. API keys should be 20-128 characters and contain only letters, numbers, dashes, and underscores.'); | ||||
|                 return; | ||||
|             } | ||||
|             updateData.api_key = validatedKey; | ||||
|         } | ||||
|          | ||||
|         fetch('/update_tts_config', { | ||||
|             method: 'POST', | ||||
| @@ -399,9 +418,33 @@ function initApp(): void { | ||||
|  | ||||
|     // Function to transcribe audio | ||||
|     const transcribeAudioBase = async function(audioBlob: Blob): Promise<void> { | ||||
|         // Validate audio file | ||||
|         const validation = Validator.validateAudioFile(new File([audioBlob], 'audio.webm', { type: audioBlob.type })); | ||||
|         if (!validation.valid) { | ||||
|             statusIndicator.textContent = validation.error || 'Invalid audio file'; | ||||
|             statusIndicator.classList.add('text-danger'); | ||||
|             hideProgress(); | ||||
|             hideLoadingOverlay(); | ||||
|             return; | ||||
|         } | ||||
|          | ||||
|         // Validate language code | ||||
|         const validatedLang = Validator.validateLanguageCode( | ||||
|             sourceLanguage.value, | ||||
|             Array.from(sourceLanguage.options).map(opt => opt.value) | ||||
|         ); | ||||
|          | ||||
|         if (!validatedLang && sourceLanguage.value !== 'auto') { | ||||
|             statusIndicator.textContent = 'Invalid source language selected'; | ||||
|             statusIndicator.classList.add('text-danger'); | ||||
|             hideProgress(); | ||||
|             hideLoadingOverlay(); | ||||
|             return; | ||||
|         } | ||||
|          | ||||
|         const formData = new FormData(); | ||||
|         formData.append('audio', audioBlob, 'audio.webm'); // Add filename for better server handling | ||||
|         formData.append('source_lang', sourceLanguage.value); | ||||
|         formData.append('audio', audioBlob, Validator.sanitizeFilename('audio.webm')); | ||||
|         formData.append('source_lang', validatedLang || 'auto'); | ||||
|          | ||||
|         // Log upload size | ||||
|         const sizeInKB = (audioBlob.size / 1024).toFixed(2); | ||||
| @@ -432,20 +475,22 @@ function initApp(): void { | ||||
|             hideProgress(); | ||||
|              | ||||
|             if (data.success && data.text) { | ||||
|                 currentSourceText = data.text; | ||||
|                 // Sanitize the transcribed text | ||||
|                 const sanitizedText = Validator.sanitizeText(data.text); | ||||
|                 currentSourceText = sanitizedText; | ||||
|                  | ||||
|                 // Handle auto-detected language | ||||
|                 if (data.detected_language && sourceLanguage.value === 'auto') { | ||||
|                     // Update the source language selector | ||||
|                     sourceLanguage.value = data.detected_language; | ||||
|                      | ||||
|                     // Show detected language info | ||||
|                     sourceText.innerHTML = `<p class="fade-in">${data.text}</p> | ||||
|                         <small class="text-muted">Detected language: ${data.detected_language}</small>`; | ||||
|                     // Show detected language info with sanitized HTML | ||||
|                     sourceText.innerHTML = `<p class="fade-in">${Validator.sanitizeHTML(sanitizedText)}</p> | ||||
|                         <small class="text-muted">Detected language: ${Validator.sanitizeHTML(data.detected_language)}</small>`; | ||||
|                      | ||||
|                     statusIndicator.textContent = `Transcription complete (${data.detected_language} detected)`; | ||||
|                 } else { | ||||
|                     sourceText.innerHTML = `<p class="fade-in">${data.text}</p>`; | ||||
|                     sourceText.innerHTML = `<p class="fade-in">${Validator.sanitizeHTML(sanitizedText)}</p>`; | ||||
|                     statusIndicator.textContent = 'Transcription complete'; | ||||
|                 } | ||||
|                  | ||||
| @@ -535,10 +580,37 @@ function initApp(): void { | ||||
|         showProgress(); | ||||
|         showLoadingOverlay('Translating to ' + targetLanguage.value + '...'); | ||||
|          | ||||
|         // Validate input text size | ||||
|         if (!Validator.validateRequestSize({ text: currentSourceText }, 100)) { | ||||
|             translatedText.innerHTML = '<p class="text-danger">Text is too long to translate. Please shorten it.</p>'; | ||||
|             statusIndicator.textContent = 'Text too long'; | ||||
|             hideProgress(); | ||||
|             hideLoadingOverlay(); | ||||
|             return; | ||||
|         } | ||||
|          | ||||
|         // Validate language codes | ||||
|         const validatedSourceLang = Validator.validateLanguageCode( | ||||
|             sourceLanguage.value, | ||||
|             Array.from(sourceLanguage.options).map(opt => opt.value) | ||||
|         ); | ||||
|         const validatedTargetLang = Validator.validateLanguageCode( | ||||
|             targetLanguage.value, | ||||
|             Array.from(targetLanguage.options).map(opt => opt.value) | ||||
|         ); | ||||
|          | ||||
|         if (!validatedTargetLang) { | ||||
|             translatedText.innerHTML = '<p class="text-danger">Invalid target language selected</p>'; | ||||
|             statusIndicator.textContent = 'Invalid language'; | ||||
|             hideProgress(); | ||||
|             hideLoadingOverlay(); | ||||
|             return; | ||||
|         } | ||||
|          | ||||
|         const requestBody: TranslationRequest = { | ||||
|             text: currentSourceText, | ||||
|             source_lang: sourceLanguage.value, | ||||
|             target_lang: targetLanguage.value | ||||
|             text: Validator.sanitizeText(currentSourceText), | ||||
|             source_lang: validatedSourceLang || 'auto', | ||||
|             target_lang: validatedTargetLang | ||||
|         }; | ||||
|          | ||||
|         try { | ||||
| @@ -567,8 +639,10 @@ function initApp(): void { | ||||
|             hideProgress(); | ||||
|              | ||||
|             if (data.success && data.translation) { | ||||
|                 currentTranslationText = data.translation; | ||||
|                 translatedText.innerHTML = `<p class="fade-in">${data.translation}</p>`; | ||||
|                 // Sanitize the translated text | ||||
|                 const sanitizedTranslation = Validator.sanitizeText(data.translation); | ||||
|                 currentTranslationText = sanitizedTranslation; | ||||
|                 translatedText.innerHTML = `<p class="fade-in">${Validator.sanitizeHTML(sanitizedTranslation)}</p>`; | ||||
|                 playTranslation.disabled = false; | ||||
|                 statusIndicator.textContent = 'Translation complete'; | ||||
|                 statusIndicator.classList.remove('processing'); | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| // Translation cache management for offline support | ||||
| import { TranslationCacheEntry, CacheStats } from './types'; | ||||
| import { Validator } from './validator'; | ||||
|  | ||||
| export class TranslationCache { | ||||
|     private static DB_NAME = 'VoiceTranslatorDB'; | ||||
| @@ -11,9 +12,10 @@ export class TranslationCache { | ||||
|  | ||||
|     // Generate cache key from input parameters | ||||
|     static generateCacheKey(text: string, sourceLang: string, targetLang: string): string { | ||||
|         // Normalize text and create a consistent key | ||||
|         // Normalize and sanitize text to create a consistent key | ||||
|         const normalizedText = text.trim().toLowerCase(); | ||||
|         return `${sourceLang}:${targetLang}:${normalizedText}`; | ||||
|         const sanitized = Validator.sanitizeCacheKey(normalizedText); | ||||
|         return `${sourceLang}:${targetLang}:${sanitized}`; | ||||
|     } | ||||
|  | ||||
|     // Open or create the cache database | ||||
|   | ||||
							
								
								
									
										259
									
								
								static/js/src/validator.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										259
									
								
								static/js/src/validator.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,259 @@ | ||||
| // Input validation and sanitization utilities | ||||
| export class Validator { | ||||
|     // Sanitize HTML to prevent XSS attacks | ||||
|     static sanitizeHTML(input: string): string { | ||||
|         // Create a temporary div element | ||||
|         const temp = document.createElement('div'); | ||||
|         temp.textContent = input; | ||||
|         return temp.innerHTML; | ||||
|     } | ||||
|  | ||||
|     // Validate and sanitize text input | ||||
|     static sanitizeText(input: string, maxLength: number = 10000): string { | ||||
|         if (typeof input !== 'string') { | ||||
|             return ''; | ||||
|         } | ||||
|          | ||||
|         // Trim and limit length | ||||
|         let sanitized = input.trim().substring(0, maxLength); | ||||
|          | ||||
|         // Remove null bytes | ||||
|         sanitized = sanitized.replace(/\0/g, ''); | ||||
|          | ||||
|         // Remove control characters except newlines and tabs | ||||
|         sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); | ||||
|          | ||||
|         return sanitized; | ||||
|     } | ||||
|  | ||||
|     // Validate language code | ||||
|     static validateLanguageCode(code: string, allowedLanguages: string[]): string | null { | ||||
|         if (!code || typeof code !== 'string') { | ||||
|             return null; | ||||
|         } | ||||
|          | ||||
|         const sanitized = code.trim().toLowerCase(); | ||||
|          | ||||
|         // Check if it's in the allowed list | ||||
|         if (allowedLanguages.includes(sanitized) || sanitized === 'auto') { | ||||
|             return sanitized; | ||||
|         } | ||||
|          | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     // Validate file upload | ||||
|     static validateAudioFile(file: File): { valid: boolean; error?: string } { | ||||
|         // Check if file exists | ||||
|         if (!file) { | ||||
|             return { valid: false, error: 'No file provided' }; | ||||
|         } | ||||
|  | ||||
|         // Check file size (max 25MB) | ||||
|         const maxSize = 25 * 1024 * 1024; | ||||
|         if (file.size > maxSize) { | ||||
|             return { valid: false, error: 'File size exceeds 25MB limit' }; | ||||
|         } | ||||
|  | ||||
|         // Check file type | ||||
|         const allowedTypes = [ | ||||
|             'audio/webm', | ||||
|             'audio/ogg', | ||||
|             'audio/wav', | ||||
|             'audio/mp3', | ||||
|             'audio/mpeg', | ||||
|             'audio/mp4', | ||||
|             'audio/x-m4a', | ||||
|             'audio/x-wav' | ||||
|         ]; | ||||
|          | ||||
|         if (!allowedTypes.includes(file.type)) { | ||||
|             // Check by extension as fallback | ||||
|             const ext = file.name.toLowerCase().split('.').pop(); | ||||
|             const allowedExtensions = ['webm', 'ogg', 'wav', 'mp3', 'mp4', 'm4a']; | ||||
|              | ||||
|             if (!ext || !allowedExtensions.includes(ext)) { | ||||
|                 return { valid: false, error: 'Invalid audio file type' }; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return { valid: true }; | ||||
|     } | ||||
|  | ||||
|     // Validate URL | ||||
|     static validateURL(url: string): string | null { | ||||
|         if (!url || typeof url !== 'string') { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             const parsed = new URL(url); | ||||
|              | ||||
|             // Only allow http and https | ||||
|             if (!['http:', 'https:'].includes(parsed.protocol)) { | ||||
|                 return null; | ||||
|             } | ||||
|              | ||||
|             // Prevent localhost in production | ||||
|             if (window.location.hostname !== 'localhost' &&  | ||||
|                 (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1')) { | ||||
|                 return null; | ||||
|             } | ||||
|              | ||||
|             return parsed.toString(); | ||||
|         } catch (e) { | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Validate API key (basic format check) | ||||
|     static validateAPIKey(key: string): string | null { | ||||
|         if (!key || typeof key !== 'string') { | ||||
|             return null; | ||||
|         } | ||||
|          | ||||
|         // Trim whitespace | ||||
|         const trimmed = key.trim(); | ||||
|          | ||||
|         // Check length (most API keys are 20-128 characters) | ||||
|         if (trimmed.length < 20 || trimmed.length > 128) { | ||||
|             return null; | ||||
|         } | ||||
|          | ||||
|         // Only allow alphanumeric, dash, and underscore | ||||
|         if (!/^[a-zA-Z0-9\-_]+$/.test(trimmed)) { | ||||
|             return null; | ||||
|         } | ||||
|          | ||||
|         return trimmed; | ||||
|     } | ||||
|  | ||||
|     // Validate request body size | ||||
|     static validateRequestSize(data: any, maxSizeKB: number = 1024): boolean { | ||||
|         try { | ||||
|             const jsonString = JSON.stringify(data); | ||||
|             const sizeInBytes = new Blob([jsonString]).size; | ||||
|             return sizeInBytes <= maxSizeKB * 1024; | ||||
|         } catch (e) { | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Sanitize filename | ||||
|     static sanitizeFilename(filename: string): string { | ||||
|         if (!filename || typeof filename !== 'string') { | ||||
|             return 'file'; | ||||
|         } | ||||
|          | ||||
|         // Remove path components | ||||
|         let name = filename.split(/[/\\]/).pop() || 'file'; | ||||
|          | ||||
|         // Remove dangerous characters | ||||
|         name = name.replace(/[^a-zA-Z0-9.\-_]/g, '_'); | ||||
|          | ||||
|         // Limit length | ||||
|         if (name.length > 255) { | ||||
|             const ext = name.split('.').pop(); | ||||
|             const base = name.substring(0, 250 - (ext ? ext.length + 1 : 0)); | ||||
|             name = ext ? `${base}.${ext}` : base; | ||||
|         } | ||||
|          | ||||
|         return name; | ||||
|     } | ||||
|  | ||||
|     // Validate settings object | ||||
|     static validateSettings(settings: any): { valid: boolean; sanitized?: any; errors?: string[] } { | ||||
|         const errors: string[] = []; | ||||
|         const sanitized: any = {}; | ||||
|  | ||||
|         // Validate notification settings | ||||
|         if (settings.notificationsEnabled !== undefined) { | ||||
|             sanitized.notificationsEnabled = Boolean(settings.notificationsEnabled); | ||||
|         } | ||||
|  | ||||
|         if (settings.notifyTranscription !== undefined) { | ||||
|             sanitized.notifyTranscription = Boolean(settings.notifyTranscription); | ||||
|         } | ||||
|  | ||||
|         if (settings.notifyTranslation !== undefined) { | ||||
|             sanitized.notifyTranslation = Boolean(settings.notifyTranslation); | ||||
|         } | ||||
|  | ||||
|         if (settings.notifyErrors !== undefined) { | ||||
|             sanitized.notifyErrors = Boolean(settings.notifyErrors); | ||||
|         } | ||||
|  | ||||
|         // Validate offline mode | ||||
|         if (settings.offlineMode !== undefined) { | ||||
|             sanitized.offlineMode = Boolean(settings.offlineMode); | ||||
|         } | ||||
|  | ||||
|         // Validate TTS settings | ||||
|         if (settings.ttsServerUrl !== undefined) { | ||||
|             const url = this.validateURL(settings.ttsServerUrl); | ||||
|             if (settings.ttsServerUrl && !url) { | ||||
|                 errors.push('Invalid TTS server URL'); | ||||
|             } else { | ||||
|                 sanitized.ttsServerUrl = url; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (settings.ttsApiKey !== undefined) { | ||||
|             const key = this.validateAPIKey(settings.ttsApiKey); | ||||
|             if (settings.ttsApiKey && !key) { | ||||
|                 errors.push('Invalid API key format'); | ||||
|             } else { | ||||
|                 sanitized.ttsApiKey = key; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return { | ||||
|             valid: errors.length === 0, | ||||
|             sanitized: errors.length === 0 ? sanitized : undefined, | ||||
|             errors: errors.length > 0 ? errors : undefined | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     // Rate limiting check | ||||
|     private static requestCounts: Map<string, number[]> = new Map(); | ||||
|      | ||||
|     static checkRateLimit( | ||||
|         action: string,  | ||||
|         maxRequests: number = 10,  | ||||
|         windowMs: number = 60000 | ||||
|     ): boolean { | ||||
|         const now = Date.now(); | ||||
|         const key = action; | ||||
|          | ||||
|         if (!this.requestCounts.has(key)) { | ||||
|             this.requestCounts.set(key, []); | ||||
|         } | ||||
|          | ||||
|         const timestamps = this.requestCounts.get(key)!; | ||||
|          | ||||
|         // Remove old timestamps | ||||
|         const cutoff = now - windowMs; | ||||
|         const recent = timestamps.filter(t => t > cutoff); | ||||
|          | ||||
|         // Check if limit exceeded | ||||
|         if (recent.length >= maxRequests) { | ||||
|             return false; | ||||
|         } | ||||
|          | ||||
|         // Add current timestamp | ||||
|         recent.push(now); | ||||
|         this.requestCounts.set(key, recent); | ||||
|          | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     // Validate translation cache key | ||||
|     static sanitizeCacheKey(key: string): string { | ||||
|         if (!key || typeof key !== 'string') { | ||||
|             return ''; | ||||
|         } | ||||
|          | ||||
|         // Remove special characters that might cause issues | ||||
|         return key.replace(/[^\w\s-]/gi, '').substring(0, 500); | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user