veza/veza-backend-api/internal/services/upload_validator.go
senke b6c004319c
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
v0.9.2
2026-03-05 19:27:34 +01:00

593 lines
19 KiB
Go

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
// Types MIME autorisés
AllowedAudioTypes []string
AllowedImageTypes []string
AllowedVideoTypes []string
// 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
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",
},
ClamAVEnabled: true,
ClamAVRequired: true, // MOD-P1-002: Par défaut, ClamAV est requis (fail-secure)
ClamAVAddress: "localhost:3310",
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 "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", },
"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
}
// 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")
}
// 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"
}
}
return "unknown"
}