package services import ( "bytes" "context" "crypto/md5" "fmt" "io" "mime/multipart" "net/http" "path/filepath" "strings" "time" "github.com/dutchcoders/go-clamd" "go.uber.org/zap" ) // UploadValidator service pour valider les uploads de fichiers type UploadValidator struct { logger *zap.Logger clamdClient *clamd.Clamd quarantineDir string clamAVRequiredButUnavailable bool // MOD-P1-001-REFINEMENT: Flag pour fail-secure localisé clamAVRequired bool // MOD-P1-002: Si false, accepte uploads même si ClamAV down } // UploadConfig configuration pour les uploads type UploadConfig struct { // Limites de taille MaxAudioSize int64 // 100MB MaxImageSize int64 // 10MB MaxVideoSize int64 // 500MB // Types MIME autorisés AllowedAudioTypes []string AllowedImageTypes []string AllowedVideoTypes []string // Configuration ClamAV ClamAVEnabled bool // Active/désactive ClamAV ClamAVRequired bool // MOD-P1-002: Si false, accepte uploads même si ClamAV down (mode dégradé) ClamAVAddress string // Dossier de quarantaine QuarantineDir string } // DefaultUploadConfig retourne la configuration par défaut func DefaultUploadConfig() *UploadConfig { return &UploadConfig{ MaxAudioSize: 100 * 1024 * 1024, // 100MB MaxImageSize: 10 * 1024 * 1024, // 10MB MaxVideoSize: 500 * 1024 * 1024, // 500MB AllowedAudioTypes: []string{ "audio/mpeg", "audio/mp3", "audio/wav", "audio/wave", // Alias valide pour WAV (http.DetectContentType retourne audio/wave) "audio/flac", "audio/aac", "audio/ogg", "audio/m4a", }, AllowedImageTypes: []string{ "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", }, AllowedVideoTypes: []string{ "video/mp4", "video/webm", "video/ogg", "video/avi", }, ClamAVEnabled: true, ClamAVRequired: true, // MOD-P1-002: Par défaut, ClamAV est requis (fail-secure) ClamAVAddress: "localhost:3310", QuarantineDir: "/quarantine", } } // NewUploadValidator crée un nouveau validateur d'upload // MOD-P1-001-REFINEMENT: Fail-secure localisé - serveur démarre même si ClamAV down, // mais uploads seront rejetés lors de la validation func NewUploadValidator(config *UploadConfig, logger *zap.Logger) (*UploadValidator, error) { fmt.Printf("🔧 [UPLOAD VALIDATOR] Initialisation - ClamAVEnabled=%v, ClamAVRequired=%v\n", config.ClamAVEnabled, config.ClamAVRequired) // EARLY RETURN: Si ClamAV est désactivé, ne JAMAIS toucher au réseau ClamAV if !config.ClamAVEnabled { fmt.Printf("✅ [UPLOAD VALIDATOR] ClamAV désactivé - Aucune connexion réseau ne sera tentée\n") logger.Info("ClamAV is disabled - virus scanning will be skipped", zap.Bool("clamav_enabled", config.ClamAVEnabled), zap.Bool("clamav_required", config.ClamAVRequired), ) return &UploadValidator{ logger: logger, clamdClient: nil, // Explicitement nil quarantineDir: config.QuarantineDir, clamAVRequiredButUnavailable: false, clamAVRequired: config.ClamAVRequired, }, nil } // ClamAV est activé - initialiser le client fmt.Printf("🛡️ [UPLOAD VALIDATOR] ClamAV activé - Initialisation client sur %s\n", config.ClamAVAddress) var clamdClient *clamd.Clamd clamAVRequiredButUnavailable := false // Créer le client ClamAV (cela ne devrait PAS faire de connexion immédiate) clamdClient = clamd.NewClamd(config.ClamAVAddress) fmt.Printf("🔌 [UPLOAD VALIDATOR] Client ClamAV créé - Test de connexion (Ping avec timeout 2s)...\n") // Test connection avec timeout pour éviter blocage - MOD-P1-001-REFINEMENT: Ne pas bloquer le démarrage // Créer un contexte avec timeout court pour le Ping pingCtx, pingCancel := context.WithTimeout(context.Background(), 2*time.Second) defer pingCancel() // Lancer le Ping dans une goroutine avec timeout pingDone := make(chan error, 1) go func() { pingDone <- clamdClient.Ping() }() var err error select { case err = <-pingDone: // Ping terminé (succès ou erreur) fmt.Printf("🔌 [UPLOAD VALIDATOR] Ping terminé - erreur: %v\n", err) case <-pingCtx.Done(): // Timeout - ClamAV ne répond pas err = fmt.Errorf("ClamAV ping timeout after 2s: %w", pingCtx.Err()) fmt.Printf("⏱️ [UPLOAD VALIDATOR] Ping timeout - ClamAV ne répond pas\n") } if err != nil { fmt.Printf("⚠️ [UPLOAD VALIDATOR] ClamAV Ping échoué: %v\n", err) if config.ClamAVRequired { // MOD-P1-002: Si ClamAV est requis, rejeter les uploads (fail-secure) logger.Warn("ClamAV is enabled and required but unavailable - uploads will be rejected until ClamAV is available", zap.Error(err), zap.String("address", config.ClamAVAddress), ) clamAVRequiredButUnavailable = true } else { // MOD-P1-002: Si ClamAV n'est pas requis, accepter uploads avec warning (mode dégradé) logger.Warn("ClamAV is enabled but unavailable and not required - uploads will be accepted without virus scanning (degraded mode)", zap.Error(err), zap.String("address", config.ClamAVAddress), zap.String("security_warning", "Virus scanning is disabled. This should only be used in development or with alternative security measures."), ) clamAVRequiredButUnavailable = false } // Ne pas retourner d'erreur - le serveur peut démarrer } else { fmt.Printf("✅ [UPLOAD VALIDATOR] ClamAV Ping réussi - Connexion OK\n") logger.Info("ClamAV connection successful") } fmt.Printf("✅ [UPLOAD VALIDATOR] Validateur initialisé - clamdClient=%v, requiredButUnavailable=%v\n", clamdClient != nil, clamAVRequiredButUnavailable) return &UploadValidator{ logger: logger, clamdClient: clamdClient, quarantineDir: config.QuarantineDir, clamAVRequiredButUnavailable: clamAVRequiredButUnavailable, clamAVRequired: config.ClamAVRequired, }, nil } // ValidationResult résultat de la validation type ValidationResult struct { Valid bool FileType string FileSize int64 Checksum string Error string Quarantined bool } // ValidateFile valide un fichier uploadé // MOD-P1-001: Le scan ClamAV se fait AVANT toute persistance func (uv *UploadValidator) ValidateFile(ctx context.Context, fileHeader *multipart.FileHeader, fileType string) (*ValidationResult, error) { // DEBUG: Log de début de validation fmt.Printf("🚀 [UPLOAD VALIDATE] Début validation fichier: %s (taille: %d bytes, type: %s)\n", fileHeader.Filename, fileHeader.Size, fileType) fmt.Printf("🔍 [UPLOAD VALIDATE] État ClamAV - client=%v, required=%v, requiredButUnavailable=%v\n", uv.clamdClient != nil, uv.clamAVRequired, uv.clamAVRequiredButUnavailable) // EARLY CHECK: Si ClamAV est complètement désactivé, on skip immédiatement if uv.clamdClient == nil { fmt.Printf("⏭️ [UPLOAD VALIDATE] ClamAV désactivé (client=nil) - scan antivirus ignoré\n") } result := &ValidationResult{ FileSize: fileHeader.Size, } // Ouvrir le fichier file, err := fileHeader.Open() if err != nil { fmt.Printf("❌ [UPLOAD] Erreur ouverture fichier: %v\n", err) result.Error = "Failed to open file" return result, err } defer file.Close() fmt.Printf("✅ [UPLOAD] Fichier ouvert avec succès\n") // BE-SEC-010: Enhanced file validation - read magic bytes for robust type detection header := make([]byte, 512) n, err := file.Read(header) if err != nil && err != io.EOF { result.Error = "Failed to read file header" return result, err } if n < 4 { result.Error = "File too small to validate" return result, nil } // Reset la position du fichier file.Seek(0, 0) // BE-SEC-010: Validate magic bytes first (more secure than MIME type alone) if err := uv.validateMagicBytes(header[:n], fileType); err != nil { result.Error = fmt.Sprintf("Invalid file signature: %v", err) return result, nil } // Détecter le type MIME réel detectedMIME := http.DetectContentType(header[:n]) result.FileType = detectedMIME // Valider le type de fichier (double-check with MIME type) if !uv.isValidFileType(detectedMIME, fileType) { result.Error = fmt.Sprintf("Invalid file type: %s (does not match expected %s)", detectedMIME, fileType) return result, nil } // Valider la taille if !uv.isValidFileSize(fileHeader.Size, fileType) { result.Error = fmt.Sprintf("File too large for type %s", fileType) return result, nil } // Calculer le checksum MD5 hash := md5.New() file.Seek(0, 0) if _, err := io.Copy(hash, file); err != nil { result.Error = "Failed to calculate checksum" return result, err } result.Checksum = fmt.Sprintf("%x", hash.Sum(nil)) // MOD-P1-001: Fail-secure - rejeter upload si ClamAV requis mais indisponible // MOD-P1-002: Si ClamAV n'est pas requis, accepter uploads même si ClamAV down (mode dégradé) // Cette vérification se fait AVANT le scan pour éviter toute persistance if uv.clamAVRequiredButUnavailable && uv.clamAVRequired { result.Error = "Virus scanning service is temporarily unavailable. Uploads are disabled for security reasons." // Retourner une erreur spéciale pour que le handler puisse retourner 503 return result, fmt.Errorf("clamav_unavailable: %s", result.Error) } // MOD-P1-002: Si ClamAV est down mais non requis, logger un warning mais continuer if uv.clamAVRequiredButUnavailable && !uv.clamAVRequired { uv.logger.Warn("ClamAV unavailable but not required - accepting upload without virus scan (degraded mode)", zap.String("filename", fileHeader.Filename), zap.String("security_warning", "File accepted without virus scanning. This should only be used in development."), ) // Continuer sans scan ClamAV } // Scanner avec ClamAV si disponible if uv.clamdClient != nil { fmt.Printf("🛡️ [UPLOAD VALIDATE] ClamAV activé - Début scan antivirus...\n") file.Seek(0, 0) scanResult, err := uv.scanWithClamAV(ctx, file) if err != nil { fmt.Printf("❌ [UPLOAD] Erreur scan ClamAV: %v\n", err) uv.logger.Error("ClamAV scan failed", zap.Error(err), zap.String("filename", fileHeader.Filename), ) // MOD-P1-001: En cas d'erreur de scan (timeout, connexion, etc.), rejeter (fail-secure) result.Error = fmt.Sprintf("Virus scan failed: %v", err) return result, fmt.Errorf("clamav_scan_error: %w", err) } // MOD-P1-001: Si virus détecté, rejeter immédiatement (code 422) if scanResult != nil && scanResult.Status != "OK" { fmt.Printf("⚠️ [UPLOAD] Virus détecté: %s\n", scanResult.Description) result.Quarantined = true result.Error = fmt.Sprintf("Virus detected: %s", scanResult.Description) uv.logger.Warn("Virus detected in uploaded file", zap.String("filename", fileHeader.Filename), zap.String("virus", scanResult.Description), ) return result, fmt.Errorf("clamav_infected: %s", scanResult.Description) } fmt.Printf("✅ [UPLOAD VALIDATE] Scan ClamAV réussi - fichier propre\n") uv.logger.Info("File scanned successfully with ClamAV", zap.String("filename", fileHeader.Filename), zap.String("status", "clean"), ) } else { fmt.Printf("⏭️ [UPLOAD VALIDATE] ClamAV désactivé (client=nil) - scan ignoré, validation continue\n") } // Valider l'extension du fichier ext := strings.ToLower(filepath.Ext(fileHeader.Filename)) if !uv.isValidExtension(ext, fileType) { result.Error = fmt.Sprintf("Invalid file extension: %s", ext) return result, nil } result.Valid = true fmt.Printf("✅ [UPLOAD VALIDATE] Validation complète - fichier accepté (Valid=true)\n") return result, nil } // isValidFileType vérifie si le type MIME est autorisé func (uv *UploadValidator) isValidFileType(mimeType, fileType string) bool { config := DefaultUploadConfig() switch fileType { case "audio": for _, allowed := range config.AllowedAudioTypes { if mimeType == allowed { return true } } case "image": for _, allowed := range config.AllowedImageTypes { if mimeType == allowed { return true } } case "video": for _, allowed := range config.AllowedVideoTypes { if mimeType == allowed { return true } } } return false } // isValidFileSize vérifie si la taille du fichier est autorisée func (uv *UploadValidator) isValidFileSize(size int64, fileType string) bool { config := DefaultUploadConfig() switch fileType { case "audio": return size <= config.MaxAudioSize case "image": return size <= config.MaxImageSize case "video": return size <= config.MaxVideoSize } return false } // isValidExtension vérifie si l'extension est valide pour le type func (uv *UploadValidator) isValidExtension(ext, fileType string) bool { extensions := map[string][]string{ "audio": {".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a"}, "image": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"}, "video": {".mp4", ".webm", ".ogg", ".avi"}, } if allowedExts, exists := extensions[fileType]; exists { for _, allowed := range allowedExts { if ext == allowed { return true } } } return false } // BE-SEC-010: validateMagicBytes validates file signature (magic bytes) to prevent file type spoofing func (uv *UploadValidator) validateMagicBytes(header []byte, fileType string) error { if len(header) < 4 { return fmt.Errorf("insufficient data for magic byte validation") } switch fileType { case "audio": return uv.validateAudioMagicBytes(header) case "image": return uv.validateImageMagicBytes(header) case "video": return uv.validateVideoMagicBytes(header) default: return fmt.Errorf("unknown file type: %s", fileType) } } // validateAudioMagicBytes validates audio file signatures func (uv *UploadValidator) validateAudioMagicBytes(header []byte) error { // MP3: ID3v2 tag (starts with "ID3") or MPEG frame sync (0xFF followed by 0xFB/0xF3/0xF2/0xE0+) if len(header) >= 3 && string(header[:3]) == "ID3" { return nil } if len(header) >= 2 && header[0] == 0xFF && (header[1] == 0xFB || header[1] == 0xF3 || header[1] == 0xF2 || (header[1]&0xE0) == 0xE0) { return nil } // FLAC: "fLaC" at offset 0 or 4 (if RIFF wrapper) if len(header) >= 4 && string(header[:4]) == "fLaC" { return nil } if len(header) >= 8 && string(header[:4]) == "RIFF" && string(header[8:12]) == "fLaC" { return nil } // WAV: "RIFF" followed by "WAVE" if len(header) >= 12 && string(header[:4]) == "RIFF" && string(header[8:12]) == "WAVE" { return nil } // OGG: "OggS" if len(header) >= 4 && string(header[:4]) == "OggS" { return nil } // AAC/M4A: Can start with various patterns, check for common ones // M4A files often start with "ftyp" at offset 4 if len(header) >= 8 && string(header[4:8]) == "ftyp" { return nil } // Some AAC files start with 0xFF 0xF1 or 0xFF 0xF9 if len(header) >= 2 && header[0] == 0xFF && (header[1] == 0xF1 || header[1] == 0xF9) { return nil } return fmt.Errorf("invalid audio file signature") } // validateImageMagicBytes validates image file signatures func (uv *UploadValidator) validateImageMagicBytes(header []byte) error { // JPEG: 0xFF 0xD8 0xFF if len(header) >= 3 && header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF { return nil } // PNG: 0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A if len(header) >= 8 && header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47 && header[4] == 0x0D && header[5] == 0x0A && header[6] == 0x1A && header[7] == 0x0A { return nil } // GIF: "GIF87a" or "GIF89a" if len(header) >= 6 && (string(header[:6]) == "GIF87a" || string(header[:6]) == "GIF89a") { return nil } // WebP: "RIFF" followed by "WEBP" at offset 8 if len(header) >= 12 && string(header[:4]) == "RIFF" && string(header[8:12]) == "WEBP" { return nil } // SVG: XML declaration or = 5 && string(header[:5]) == "= 4 && strings.HasPrefix(strings.ToLower(string(header)), "= 8 && string(header[4:8]) == "ftyp" { return nil } // WebM: "1A 45 DF A3" (EBML header) if len(header) >= 4 && header[0] == 0x1A && header[1] == 0x45 && header[2] == 0xDF && header[3] == 0xA3 { return nil } // OGG: "OggS" (OGG container, can contain video) if len(header) >= 4 && string(header[:4]) == "OggS" { return nil } // AVI: "RIFF" followed by "AVI " at offset 8 if len(header) >= 12 && string(header[:4]) == "RIFF" && string(header[8:12]) == "AVI " { return nil } return fmt.Errorf("invalid video file signature") } // scanWithClamAV scanne le fichier avec ClamAV avec timeout strict // MOD-P1-001: Timeout strict via context pour éviter les blocages func (uv *UploadValidator) scanWithClamAV(ctx context.Context, file io.Reader) (*clamd.ScanResult, error) { // Timeout strict: 30 secondes max pour le scan scanCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() // Lire tout le fichier en mémoire pour le scan var buf bytes.Buffer if _, err := io.Copy(&buf, file); err != nil { return nil, fmt.Errorf("failed to read file for scan: %w", err) } // Scanner avec ClamAV - ScanStream retourne (chan *ScanResult, error) controlChan := make(chan bool, 1) responseChan, err := uv.clamdClient.ScanStream(&buf, controlChan) if err != nil { return nil, fmt.Errorf("clamav scan initialization failed: %w", err) } // Lire le premier résultat avec timeout select { case result := <-responseChan: if result == nil { // Pas de résultat = OK (pas de virus détecté) return &clamd.ScanResult{Status: "OK"}, nil } return result, nil case <-scanCtx.Done(): // Timeout: annuler le scan controlChan <- true return nil, fmt.Errorf("clamav scan timeout: %w", scanCtx.Err()) } } // QuarantineFile met un fichier en quarantaine func (uv *UploadValidator) QuarantineFile(fileHeader *multipart.FileHeader, reason string) error { // Créer le nom de fichier avec timestamp timestamp := time.Now().Format("20060102_150405") filename := fmt.Sprintf("%s_%s_%s", timestamp, fileHeader.Filename, reason) quarantinePath := filepath.Join(uv.quarantineDir, filename) // Ouvrir le fichier source srcFile, err := fileHeader.Open() if err != nil { return fmt.Errorf("failed to open source file: %w", err) } defer srcFile.Close() // Créer le fichier de quarantaine // Note: Dans un vrai environnement, il faudrait créer le dossier s'il n'existe pas // et gérer les permissions appropriées uv.logger.Warn("File quarantined", zap.String("original_name", fileHeader.Filename), zap.String("quarantine_path", quarantinePath), zap.String("reason", reason), ) return nil } // GetFileTypeFromPath détermine le type de fichier à partir du chemin func (uv *UploadValidator) GetFileTypeFromPath(filename string) string { ext := strings.ToLower(filepath.Ext(filename)) audioExts := []string{".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a"} imageExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"} videoExts := []string{".mp4", ".webm", ".ogg", ".avi"} for _, audioExt := range audioExts { if ext == audioExt { return "audio" } } for _, imageExt := range imageExts { if ext == imageExt { return "image" } } for _, videoExt := range videoExts { if ext == videoExt { return "video" } } return "unknown" }