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>
259 lines
7.8 KiB
TypeScript
259 lines
7.8 KiB
TypeScript
// 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);
|
|
}
|
|
} |