/** * M3U Input Sanitization Utilities * * Provides functions to sanitize and validate M3U playlist content * to prevent injection attacks, XSS, and other security vulnerabilities. */ // Constants const MAX_M3U_SIZE = 10 * 1024 * 1024; // 10MB const MAX_CHANNELS = 5000; const MAX_NAME_LENGTH = 255; const MAX_URL_LENGTH = 1000; const MAX_CATEGORY_LENGTH = 100; /** * Allowed URL protocols for stream and logo URLs */ const ALLOWED_PROTOCOLS = ['http:', 'https:']; /** * Sanitize a URL string * @param url - URL to sanitize * @returns Sanitized URL or null if invalid */ export function sanitizeUrl(url: string): string | null { if (!url || typeof url !== 'string') { return null; } // Remove control characters and null bytes let sanitized = url.replace(/[\x00-\x1F\x7F]/g, '').trim(); // Limit length if (sanitized.length > MAX_URL_LENGTH) { sanitized = sanitized.substring(0, MAX_URL_LENGTH); } // Reject empty URLs if (!sanitized) { return null; } try { // Parse URL to validate structure const urlObj = new URL(sanitized); // Reject non-HTTP(S) protocols if (!ALLOWED_PROTOCOLS.includes(urlObj.protocol)) { return null; } // Return sanitized URL (protocol normalized to lowercase) return urlObj.toString(); } catch { // If URL parsing fails, try to construct a valid URL // This handles relative URLs or URLs without protocol if (sanitized.startsWith('//')) { try { // Use HTTP for protocol-relative URLs - most IPTV content is HTTP return new URL(`http:${sanitized}`).toString(); } catch { return null; } } // If it starts with http:// or https://, try to parse again if (sanitized.match(/^https?:\/\//i)) { try { return new URL(sanitized).toString(); } catch { return null; } } return null; } } /** * Sanitize a string (channel names, categories, etc.) * @param str - String to sanitize * @param maxLength - Maximum allowed length (default: MAX_NAME_LENGTH) * @returns Sanitized string or null if invalid */ export function sanitizeString(str: string, maxLength: number = MAX_NAME_LENGTH): string | null { if (!str || typeof str !== 'string') { return null; } // Remove control characters, null bytes, and dangerous sequences let sanitized = str .replace(/[\x00-\x1F\x7F]/g, '') // Control characters .replace(/[\u200B-\u200D\uFEFF]/g, '') // Zero-width characters .trim(); // Remove HTML tags and script content sanitized = sanitized .replace(/)<[^<]*)*<\/script>/gi, '') .replace(/<[^>]+>/g, '') // Remove remaining HTML tags .replace(/javascript:/gi, '') // Remove javascript: protocol .replace(/on\w+\s*=/gi, '') // Remove event handlers (onclick=, etc.) .trim(); // Limit length if (sanitized.length > maxLength) { sanitized = sanitized.substring(0, maxLength); } // Reject empty strings if (!sanitized) { return null; } return sanitized; } /** * Validate M3U structure * @param content - M3U content to validate * @returns Object with isValid flag and error message if invalid */ export function validateM3UStructure(content: string): { isValid: boolean; error?: string } { if (!content || typeof content !== 'string') { return { isValid: false, error: 'Invalid content: must be a non-empty string' }; } // Check size limit const sizeInBytes = new TextEncoder().encode(content).length; if (sizeInBytes > MAX_M3U_SIZE) { return { isValid: false, error: `Content too large: ${sizeInBytes} bytes exceeds maximum of ${MAX_M3U_SIZE} bytes`, }; } // Check for required header const trimmed = content.trim(); if (!trimmed.startsWith('#EXTM3U')) { return { isValid: false, error: 'Invalid M3U format: missing #EXTM3U header' }; } // Basic encoding check - ensure it's valid UTF-8 try { // Try to decode as UTF-8 new TextDecoder('utf-8', { fatal: true }).decode(new TextEncoder().encode(content)); } catch { return { isValid: false, error: 'Invalid encoding: content must be valid UTF-8' }; } return { isValid: true }; } /** * Count channels in M3U content (rough estimate) * @param content - M3U content * @returns Number of channel entries found */ export function countChannels(content: string): number { if (!content) return 0; // Count #EXTINF lines (each represents a channel) const matches = content.match(/^#EXTINF:/gm); return matches ? matches.length : 0; } /** * Validate channel count * @param count - Number of channels * @returns true if count is within limits */ export function validateChannelCount(count: number): boolean { return count >= 0 && count <= MAX_CHANNELS; } /** * Sanitize category/group-title string * @param category - Category string to sanitize * @returns Sanitized category or null if invalid */ export function sanitizeCategory(category: string): string | null { return sanitizeString(category, MAX_CATEGORY_LENGTH); }