package services import ( "bytes" "context" "crypto/sha256" "fmt" "io" "mime/multipart" "net/http" "os" "os/exec" "path/filepath" "strings" "time" "go.uber.org/zap" ) // UploadValidator service pour valider les uploads de fichiers type UploadValidator struct { logger *zap.Logger clamdPath string // Chemin vers clamdscan (ex: clamdscan, /usr/bin/clamdscan) clamAVConfigPath string // Config pour connexion TCP distante (ex: clamav:3310) 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 MaxFileSize int64 // 50MB (chat attachments, PDF) // Types MIME autorisés AllowedAudioTypes []string AllowedImageTypes []string AllowedVideoTypes []string AllowedFileTypes []string // PDF, etc. // Configuration ClamAV (utilise clamdscan en exec, remplace go-clamd abandonné) 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 // Gardé pour compat config, non utilisé (clamdscan utilise socket par défaut) ClamAVClamdPath string // Chemin vers clamdscan (ex: clamdscan, /usr/bin/clamdscan) // Dossier de quarantaine QuarantineDir string } // DefaultUploadConfig retourne la configuration par défaut // TASK-SEC-005: MaxAudioSize 500MB (roadmap), MaxVideoSize 500MB func DefaultUploadConfig() *UploadConfig { return &UploadConfig{ MaxAudioSize: 500 * 1024 * 1024, // 500MB (TASK-SEC-005) MaxImageSize: 10 * 1024 * 1024, // 10MB MaxVideoSize: 500 * 1024 * 1024, // 500MB MaxFileSize: 50 * 1024 * 1024, // 50MB (chat attachments) 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", }, AllowedVideoTypes: []string{ "video/mp4", "video/webm", "video/ogg", "video/avi", }, AllowedFileTypes: []string{"application/pdf"}, ClamAVEnabled: true, ClamAVRequired: true, // MOD-P1-002: Par défaut, ClamAV est requis (fail-secure) ClamAVAddress: "localhost:13310", ClamAVClamdPath: "clamdscan", 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) { logger.Info("Upload validator initialization", zap.Bool("clamav_enabled", config.ClamAVEnabled), zap.Bool("clamav_required", config.ClamAVRequired)) // EARLY RETURN: Si ClamAV est désactivé, ne JAMAIS toucher à clamdscan if !config.ClamAVEnabled { 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, clamdPath: "", quarantineDir: config.QuarantineDir, clamAVRequiredButUnavailable: false, clamAVRequired: config.ClamAVRequired, }, nil } // ClamAV activé - utiliser clamdscan (exec) au lieu de go-clamd abandonné clamdPath := config.ClamAVClamdPath if clamdPath == "" { clamdPath = "clamdscan" } logger.Info("ClamAV enabled, using exec", zap.String("clamd_path", clamdPath)) // Config pour connexion TCP distante (ex: clamav:3310 en Docker) clamAVConfigPath := "" if config.ClamAVAddress != "" && strings.Contains(config.ClamAVAddress, ":") { parts := strings.SplitN(config.ClamAVAddress, ":", 2) host, port := parts[0], parts[1] configContent := fmt.Sprintf("TCPSocket %s\nTCPAddr %s\n", port, host) tmpFile, err := os.CreateTemp("", "veza-clamd-*.conf") if err == nil { _, _ = tmpFile.WriteString(configContent) _ = tmpFile.Close() clamAVConfigPath = tmpFile.Name() logger.Info("ClamAV TCP config created", zap.String("address", config.ClamAVAddress), zap.String("config_path", clamAVConfigPath)) } } clamAVRequiredButUnavailable := false // Test disponibilité: clamdscan --version pingCtx, pingCancel := context.WithTimeout(context.Background(), 2*time.Second) defer pingCancel() if err := exec.CommandContext(pingCtx, clamdPath, "--version").Run(); err != nil { if config.ClamAVRequired { logger.Warn("ClamAV is enabled and required but clamdscan unavailable - uploads will be rejected", zap.Error(err), zap.String("clamd_path", clamdPath), ) clamAVRequiredButUnavailable = true } else { logger.Warn("ClamAV enabled but clamdscan unavailable and not required - degraded mode", zap.Error(err), zap.String("clamd_path", clamdPath), ) } } else { logger.Info("ClamAV (clamdscan) available for virus scanning") } return &UploadValidator{ logger: logger, clamdPath: clamdPath, clamAVConfigPath: clamAVConfigPath, 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) { uv.logger.Debug("Validation started", zap.String("filename", fileHeader.Filename), zap.Int64("size", fileHeader.Size), zap.String("file_type", fileType), zap.Bool("clamav_enabled", uv.clamdPath != ""), zap.Bool("clamav_required", uv.clamAVRequired), zap.Bool("clamav_required_but_unavailable", uv.clamAVRequiredButUnavailable)) // EARLY CHECK: Si ClamAV est complètement désactivé, on skip immédiatement if uv.clamdPath == "" { uv.logger.Debug("ClamAV disabled - virus scan skipped") } result := &ValidationResult{ FileSize: fileHeader.Size, } // Ouvrir le fichier file, err := fileHeader.Open() if err != nil { uv.logger.Error("Failed to open file for validation", zap.String("filename", fileHeader.Filename), zap.Error(err)) result.Error = "Failed to open file" return result, err } defer file.Close() uv.logger.Debug("File opened successfully", zap.String("filename", fileHeader.Filename)) // 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 SHA256 (SEC-007: MD5 remplacé par SHA256) hash := sha256.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 (clamdscan exec) if uv.clamdPath != "" { uv.logger.Debug("ClamAV scan starting", zap.String("filename", fileHeader.Filename)) file.Seek(0, 0) infected, err := uv.scanWithClamAV(ctx, file) if err != nil { 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 infected { result.Quarantined = true result.Error = "Virus detected" uv.logger.Warn("Virus detected in uploaded file", zap.String("filename", fileHeader.Filename), ) return result, fmt.Errorf("clamav_infected: virus detected") } uv.logger.Info("File scanned successfully with ClamAV", zap.String("filename", fileHeader.Filename), zap.String("status", "clean"), ) } else { uv.logger.Debug("ClamAV disabled - scan skipped, validation continues") } // 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 uv.logger.Debug("Validation complete - file accepted", zap.String("filename", fileHeader.Filename)) 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 "file": for _, allowed := range config.AllowedFileTypes { if mimeType == allowed { return true } } 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 "file": return size <= config.MaxFileSize 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"}, "video": {".mp4", ".webm", ".ogg", ".avi"}, "file": {".pdf"}, } 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) case "file": return uv.validateFileMagicBytes(header) // PDF 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 } // SEC-026: SVG explicitly rejected — XSS risk without sanitization. // AllowedImageTypes does not include image/svg+xml; magic bytes must not accept SVG either. return fmt.Errorf("invalid image file signature") } // validateFileMagicBytes validates document file signatures (PDF) func (uv *UploadValidator) validateFileMagicBytes(header []byte) error { if len(header) >= 4 && string(header[:4]) == "%PDF" { return nil } return fmt.Errorf("invalid PDF file signature") } // validateVideoMagicBytes validates video file signatures func (uv *UploadValidator) validateVideoMagicBytes(header []byte) error { // MP4: "ftyp" at offset 4 (after 4-byte size field) if len(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 clamdscan (exec) - remplace go-clamd abandonné // MOD-P1-001: Timeout strict via context pour éviter les blocages // clamdscan exit 0 = clean, exit 1 = infected, exit 2 = error func (uv *UploadValidator) scanWithClamAV(ctx context.Context, file io.Reader) (infected bool, err error) { scanCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() tmpFile, err := os.CreateTemp("", "veza-clamav-scan-*") if err != nil { return false, fmt.Errorf("failed to create temp file for scan: %w", err) } tmpPath := tmpFile.Name() defer func() { _ = os.Remove(tmpPath) }() if _, copyErr := io.Copy(tmpFile, file); copyErr != nil { _ = tmpFile.Close() return false, fmt.Errorf("failed to write file for scan: %w", copyErr) } if closeErr := tmpFile.Close(); closeErr != nil { return false, fmt.Errorf("failed to close temp file: %w", closeErr) } args := []string{"--no-summary"} if uv.clamAVConfigPath != "" { args = append(args, "-c", uv.clamAVConfigPath) } args = append(args, tmpPath) cmd := exec.CommandContext(scanCtx, uv.clamdPath, args...) var stderr bytes.Buffer cmd.Stderr = &stderr runErr := cmd.Run() if runErr != nil { if exitErr, ok := runErr.(*exec.ExitError); ok { switch exitErr.ExitCode() { case 1: return true, nil // Virus détecté case 2: return false, fmt.Errorf("clamdscan error: %s", stderr.String()) } } return false, fmt.Errorf("clamdscan failed: %w", runErr) } return false, nil // Exit 0 = clean } // 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"} 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" } } if ext == ".pdf" { return "file" } return "unknown" }