468 lines
15 KiB
Go
468 lines
15 KiB
Go
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")
|
|
|
|
// Lire les premiers bytes pour vérifier le magic number
|
|
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
|
|
}
|
|
|
|
// Reset la position du fichier
|
|
file.Seek(0, 0)
|
|
|
|
// Détecter le type MIME réel
|
|
detectedMIME := http.DetectContentType(header[:n])
|
|
result.FileType = detectedMIME
|
|
|
|
// Valider le type de fichier
|
|
if !uv.isValidFileType(detectedMIME, fileType) {
|
|
result.Error = fmt.Sprintf("Invalid file type: %s", detectedMIME)
|
|
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
|
|
}
|
|
|
|
// 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"
|
|
}
|