From b6c004319c3076529f5d03de75e8fd2366457f73 Mon Sep 17 00:00:00 2001 From: senke Date: Thu, 5 Mar 2026 19:27:34 +0100 Subject: [PATCH] v0.9.2 --- docs/ENV_VARIABLES.md | 43 ++++++++ veza-backend-api/internal/api/routes_core.go | 13 +-- .../internal/common/validation.go | 28 ++++-- .../internal/common/validation_test.go | 20 +++- .../internal/config/middlewares_init.go | 21 ++-- .../internal/config/rate_limit.go | 16 +++ veza-backend-api/internal/handlers/common.go | 17 ++-- .../internal/middleware/metrics_protection.go | 99 +++++++++++++++++++ .../middleware/metrics_protection_test.go | 95 ++++++++++++++++++ .../internal/middleware/rate_limiter.go | 74 +++++++++----- .../internal/services/upload_validator.go | 3 +- .../services/upload_validator_test.go | 8 ++ 12 files changed, 375 insertions(+), 62 deletions(-) create mode 100644 veza-backend-api/internal/middleware/metrics_protection.go create mode 100644 veza-backend-api/internal/middleware/metrics_protection_test.go diff --git a/docs/ENV_VARIABLES.md b/docs/ENV_VARIABLES.md index 3a571f91b..9f7bc064a 100644 --- a/docs/ENV_VARIABLES.md +++ b/docs/ENV_VARIABLES.md @@ -60,6 +60,49 @@ openssl rsa -in jwt-private.pem -pubout -out jwt-public.pem | `CHAT_SERVER_URL` | URL chat (legacy) | string | Non | `http://veza.fr:8081` | — | | `RABBITMQ_URL` | URL RabbitMQ | string | Non | — | `amqp://veza:password@localhost:5672/` | +### Rate limiting (v0.9.2 TASK-SEC-003) + +| Variable | Description | Type | Requis | Valeur par défaut | Exemple | +|----------|--------------|------|--------|------------------|---------| +| `RATE_LIMIT_IP_PER_HOUR` | Requêtes/heure par IP (non-auth) | int | Non | 100 (prod), 500 (dev) | `100` | +| `RATE_LIMIT_USER_PER_HOUR` | Requêtes/heure par utilisateur auth | int | Non | 1000 (prod), 2000 (dev) | `1000` | + +### Endpoint /metrics (v0.9.2 TASK-SEC-006) + +| Variable | Description | Type | Requis | Valeur par défaut | Exemple | +|----------|--------------|------|--------|------------------|---------| +| `METRICS_BEARER_TOKEN` | Token Bearer pour accès Prometheus | string | Non | — | secret partagé | +| `METRICS_ALLOWED_IPS` | IPs autorisées (exact ou CIDR, CSV) | string | Non | — | `10.0.0.0/8,127.0.0.1` | +| `METRICS_PUBLIC_IN_DEV` | Si `true` et non-prod, accès sans auth | bool | Non | `false` | `true` | + +**Note :** Si ni `METRICS_BEARER_TOKEN` ni `METRICS_ALLOWED_IPS` n'est défini, l'accès à `/metrics` est refusé (403) en production. + +### Taille des payloads (v0.9.2 TASK-SEC-005) + +| Variable | Description | Type | Requis | Valeur par défaut | Exemple | +|----------|--------------|------|--------|------------------|---------| +| `MAX_JSON_BODY_SIZE` | Taille max body JSON (bytes) | int | Non | `1048576` (1MB) | `2097152` (2MB) | + +**Upload audio :** 500MB par défaut (`UploadConfig.MaxAudioSize` dans `services.DefaultUploadConfig`). + +### Security headers (TASK-SEC-004) + +Headers HTTP de sécurité appliqués globalement via `middleware.SecurityHeaders()` (router.go) : + +| Header | Valeur | Rôle | +|--------|--------|------| +| Strict-Transport-Security | max-age=31536000; includeSubDomains; preload | HSTS (prod) | +| X-Content-Type-Options | nosniff | Anti MIME sniffing | +| X-Frame-Options | DENY (SAMEORIGIN pour Swagger) | Anti clickjacking | +| X-XSS-Protection | 1; mode=block | XSS legacy | +| Referrer-Policy | strict-origin-when-cross-origin | Referrer | +| Permissions-Policy | geolocation=(), microphone=(), … | Restriction features | +| Content-Security-Policy | default-src 'none'; … | CSP | +| X-Permitted-Cross-Domain-Policies | none | Flash/PDF | +| Cross-Origin-Embedder-Policy | require-corp | COEP | +| Cross-Origin-Opener-Policy | same-origin | COOP | +| Cross-Origin-Resource-Policy | same-origin | CORP | + --- ## Stream Server (veza-stream-server) diff --git a/veza-backend-api/internal/api/routes_core.go b/veza-backend-api/internal/api/routes_core.go index 8a85be907..1c5afb01b 100644 --- a/veza-backend-api/internal/api/routes_core.go +++ b/veza-backend-api/internal/api/routes_core.go @@ -143,16 +143,17 @@ func (r *APIRouter) setupCorePublicRoutes(router *gin.Engine) { deprecationMW := middleware.DeprecationWarning(r.logger) healthMonitoringMW := middleware.HealthCheckMonitoring(r.logger, r.monitoringService) + metricsProtection := middleware.MetricsProtection(r.logger) router.GET("/health", deprecationMW, healthMonitoringMW, healthCheckHandler) router.GET("/health/deep", deprecationMW, healthMonitoringMW, deepHealthHandler) router.GET("/healthz", deprecationMW, healthMonitoringMW, livenessHandler) router.GET("/readyz", deprecationMW, healthMonitoringMW, readinessHandler) - router.GET("/metrics", deprecationMW, handlers.PrometheusMetrics()) + router.GET("/metrics", deprecationMW, metricsProtection, handlers.PrometheusMetrics()) if r.config != nil && r.config.ErrorMetrics != nil { - router.GET("/metrics/aggregated", deprecationMW, handlers.AggregatedMetrics(r.config.ErrorMetrics)) + router.GET("/metrics/aggregated", deprecationMW, metricsProtection, handlers.AggregatedMetrics(r.config.ErrorMetrics)) } - router.GET("/system/metrics", deprecationMW, handlers.SystemMetrics) + router.GET("/system/metrics", deprecationMW, metricsProtection, handlers.SystemMetrics) v1Public := router.Group("/api/v1") { @@ -199,11 +200,11 @@ func (r *APIRouter) setupCorePublicRoutes(router *gin.Engine) { v1Public.GET("/status", statusHandler.GetStatus) } - v1Public.GET("/metrics", handlers.PrometheusMetrics()) + v1Public.GET("/metrics", metricsProtection, handlers.PrometheusMetrics()) if r.config != nil && r.config.ErrorMetrics != nil { - v1Public.GET("/metrics/aggregated", handlers.AggregatedMetrics(r.config.ErrorMetrics)) + v1Public.GET("/metrics/aggregated", metricsProtection, handlers.AggregatedMetrics(r.config.ErrorMetrics)) } - v1Public.GET("/system/metrics", handlers.SystemMetrics) + v1Public.GET("/system/metrics", metricsProtection, handlers.SystemMetrics) if r.db != nil && r.db.GormDB != nil && r.config != nil { uploadConfig := getUploadConfigWithEnv() diff --git a/veza-backend-api/internal/common/validation.go b/veza-backend-api/internal/common/validation.go index 7e7fc563f..636780d6e 100644 --- a/veza-backend-api/internal/common/validation.go +++ b/veza-backend-api/internal/common/validation.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "net/http" + "os" + "strconv" "strings" "veza-backend-api/internal/response" @@ -15,8 +17,21 @@ import ( "github.com/go-playground/validator/v10" ) -// MaxJSONBodySize définit la taille maximale du body JSON (10MB par défaut) -const MaxJSONBodySize = 10 * 1024 * 1024 // 10MB +// DefaultMaxJSONBodySize TASK-SEC-005: 1MB par défaut (configurable via MAX_JSON_BODY_SIZE) +const DefaultMaxJSONBodySize = 1 * 1024 * 1024 // 1MB + +// GetMaxJSONBodySize returns the max JSON body size from MAX_JSON_BODY_SIZE env (bytes), default 1MB. +func GetMaxJSONBodySize() int64 { + if v := os.Getenv("MAX_JSON_BODY_SIZE"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 { + return n + } + } + return DefaultMaxJSONBodySize +} + +// MaxJSONBodySize kept for backward compatibility in tests; use GetMaxJSONBodySize() for runtime limit. +var MaxJSONBodySize = DefaultMaxJSONBodySize // BindAndValidateJSON lie et valide les données JSON de la requête de manière robuste // MOD-P1-002: Helper centralisé pour bind + validate + format d'erreur standard @@ -35,14 +50,15 @@ const MaxJSONBodySize = 10 * 1024 * 1024 // 10MB // return // Erreur déjà envoyée au client // } func BindAndValidateJSON(c *gin.Context, obj interface{}) bool { + maxSize := GetMaxJSONBodySize() // 1. Vérifier la taille du body - if c.Request.ContentLength > MaxJSONBodySize { - response.BadRequest(c, fmt.Sprintf("Request body too large: maximum size is %d bytes", MaxJSONBodySize)) + if c.Request.ContentLength > maxSize { + response.BadRequest(c, fmt.Sprintf("Request body too large: maximum size is %d bytes", maxSize)) return false } // 2. Limiter la lecture du body pour éviter les attaques par body trop gros - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, MaxJSONBodySize) + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize) // 3. Parser le JSON avec ShouldBindJSON if err := c.ShouldBindJSON(obj); err != nil { @@ -85,7 +101,7 @@ func handleBindingError(c *gin.Context, err error) { switch { case errors.As(err, &maxBytesError): // Body trop gros - response.BadRequest(c, fmt.Sprintf("Request body too large: maximum size is %d bytes", MaxJSONBodySize)) + response.BadRequest(c, fmt.Sprintf("Request body too large: maximum size is %d bytes", GetMaxJSONBodySize())) case errors.As(err, &jsonSyntaxError): // JSON syntaxiquement invalide diff --git a/veza-backend-api/internal/common/validation_test.go b/veza-backend-api/internal/common/validation_test.go index eed997123..64b50c7d1 100644 --- a/veza-backend-api/internal/common/validation_test.go +++ b/veza-backend-api/internal/common/validation_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "testing" "github.com/gin-gonic/gin" @@ -12,6 +13,19 @@ import ( "github.com/stretchr/testify/assert" ) +// TestGetMaxJSONBodySize vérifie la limite par défaut et l'override via env (TASK-SEC-005) +func TestGetMaxJSONBodySize(t *testing.T) { + defer os.Unsetenv("MAX_JSON_BODY_SIZE") + + // Défaut 1MB + os.Unsetenv("MAX_JSON_BODY_SIZE") + assert.Equal(t, int64(1024*1024), GetMaxJSONBodySize()) + + // Override via env + os.Setenv("MAX_JSON_BODY_SIZE", "2097152") // 2MB + assert.Equal(t, int64(2097152), GetMaxJSONBodySize()) +} + // TestRequest est un DTO de test avec validation type TestRequest struct { Title string `json:"title" binding:"required,min=1,max=255" validate:"required,min=1,max=255"` @@ -221,7 +235,7 @@ func TestBindAndValidateJSON_InvalidJSON(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) } -// TestBindAndValidateJSON_BodyTooLarge vérifie qu'un body trop gros est rejeté +// TestBindAndValidateJSON_BodyTooLarge vérifie qu'un body trop gros est rejeté (TASK-SEC-005: 1MB défaut) func TestBindAndValidateJSON_BodyTooLarge(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() @@ -234,8 +248,8 @@ func TestBindAndValidateJSON_BodyTooLarge(t *testing.T) { c.JSON(http.StatusOK, gin.H{"success": true}) }) - // Créer un body > 10MB - largeBody := make([]byte, MaxJSONBodySize+1) + // Créer un body > limite (1MB par défaut) + largeBody := make([]byte, GetMaxJSONBodySize()+1) req := httptest.NewRequest("POST", "/test", bytes.NewBuffer(largeBody)) req.Header.Set("Content-Type", "application/json") req.ContentLength = int64(len(largeBody)) diff --git a/veza-backend-api/internal/config/middlewares_init.go b/veza-backend-api/internal/config/middlewares_init.go index 2ed9c0481..26537226f 100644 --- a/veza-backend-api/internal/config/middlewares_init.go +++ b/veza-backend-api/internal/config/middlewares_init.go @@ -15,20 +15,17 @@ func (c *Config) InitMiddlewaresForTest() error { // initMiddlewares initialise tous les middlewares func (c *Config) initMiddlewares() error { - // Rate limiter global (avec Redis) - // En développement, augmenter les limites pour éviter les erreurs lors des tests - ipLimit := getEnvInt("RATE_LIMIT_IP_PER_MINUTE", 200) // Augmenté de 100 à 200 en dev - ipBurst := getEnvInt("RATE_LIMIT_IP_BURST", 20) // Augmenté de 10 à 20 en dev - userLimit := getEnvInt("RATE_LIMIT_USER_PER_MINUTE", 1000) - userBurst := getEnvInt("RATE_LIMIT_USER_BURST", 100) + // Rate limiter global (TASK-SEC-003: 100 req/h non-auth, 1000 req/h auth in prod) + ipLimit := getDefaultRateLimitIPPerHour(c.Env) + userLimit := getDefaultRateLimitUserPerHour(c.Env) + windowSeconds := 3600 // 1 hour rateLimiterConfig := &middleware.RateLimiterConfig{ - IPRequestsPerMinute: ipLimit, - IPBurst: ipBurst, - UserRequestsPerMinute: userLimit, - UserBurst: userBurst, - RedisClient: c.RedisClient, - KeyPrefix: "veza:rate_limit", + IPLimit: ipLimit, + UserLimit: userLimit, + WindowSeconds: windowSeconds, + RedisClient: c.RedisClient, + KeyPrefix: "veza:rate_limit", } c.RateLimiter = middleware.NewRateLimiter(rateLimiterConfig) diff --git a/veza-backend-api/internal/config/rate_limit.go b/veza-backend-api/internal/config/rate_limit.go index 62275c508..35a5ef956 100644 --- a/veza-backend-api/internal/config/rate_limit.go +++ b/veza-backend-api/internal/config/rate_limit.go @@ -41,3 +41,19 @@ func getAuthRateLimitLoginWindow(env string) int { // La fenêtre reste la même, seule la limite de tentatives change return getEnvInt("AUTH_RATE_LIMIT_LOGIN_WINDOW", 1) } + +// getDefaultRateLimitIPPerHour returns default hourly limit for non-auth (TASK-SEC-003) +func getDefaultRateLimitIPPerHour(env string) int { + if env == EnvDevelopment || env == EnvTest { + return getEnvInt("RATE_LIMIT_IP_PER_HOUR", 500) // More relaxed in dev + } + return getEnvInt("RATE_LIMIT_IP_PER_HOUR", 100) // 100 req/h in prod +} + +// getDefaultRateLimitUserPerHour returns default hourly limit for auth (TASK-SEC-003) +func getDefaultRateLimitUserPerHour(env string) int { + if env == EnvDevelopment || env == EnvTest { + return getEnvInt("RATE_LIMIT_USER_PER_HOUR", 2000) // More relaxed in dev + } + return getEnvInt("RATE_LIMIT_USER_PER_HOUR", 1000) // 1000 req/h in prod +} diff --git a/veza-backend-api/internal/handlers/common.go b/veza-backend-api/internal/handlers/common.go index fd9b8ba32..a447cbc5d 100644 --- a/veza-backend-api/internal/handlers/common.go +++ b/veza-backend-api/internal/handlers/common.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "veza-backend-api/internal/common" "veza-backend-api/internal/dto" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/validators" @@ -206,9 +207,6 @@ func (h *CommonHandler) BindJSON(c *gin.Context, obj interface{}) error { return nil } -// MaxJSONBodySize définit la taille maximale du body JSON (10MB par défaut) -const MaxJSONBodySize = 10 * 1024 * 1024 // 10MB - // BindAndValidateJSON lie et valide les données JSON de la requête de manière robuste // P0: JSON Hardening - Garantit qu'aucune erreur de parsing/validation ne passe silencieusement // @@ -227,23 +225,24 @@ const MaxJSONBodySize = 10 * 1024 * 1024 // 10MB // } func (h *CommonHandler) BindAndValidateJSON(c *gin.Context, obj interface{}) *apperrors.AppError { requestID := c.GetString("request_id") + maxSize := common.GetMaxJSONBodySize() - // 1. Vérifier la taille du body - if c.Request.ContentLength > MaxJSONBodySize { + // 1. Vérifier la taille du body (TASK-SEC-005: 1MB défaut) + if c.Request.ContentLength > maxSize { h.logger.Warn("Request body too large", zap.Int64("content_length", c.Request.ContentLength), - zap.Int64("max_size", MaxJSONBodySize), + zap.Int64("max_size", maxSize), zap.String("request_id", requestID), zap.String("endpoint", c.Request.URL.Path), ) return apperrors.New( apperrors.ErrCodeValidation, - fmt.Sprintf("Request body too large: maximum size is %d bytes", MaxJSONBodySize), + fmt.Sprintf("Request body too large: maximum size is %d bytes", maxSize), ) } // 2. Limiter la lecture du body pour éviter les attaques par body trop gros - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, MaxJSONBodySize) + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize) // 3. Parser le JSON avec DisallowUnknownFields (rejette les champs inconnus) body, err := io.ReadAll(c.Request.Body) @@ -275,7 +274,7 @@ func (h *CommonHandler) BindAndValidateJSON(c *gin.Context, obj interface{}) *ap ) return apperrors.New( apperrors.ErrCodeValidation, - fmt.Sprintf("Request body too large: maximum size is %d bytes", MaxJSONBodySize), + fmt.Sprintf("Request body too large: maximum size is %d bytes", common.GetMaxJSONBodySize()), ) case errors.As(err, &jsonSyntaxError): diff --git a/veza-backend-api/internal/middleware/metrics_protection.go b/veza-backend-api/internal/middleware/metrics_protection.go new file mode 100644 index 000000000..4ce9f5a54 --- /dev/null +++ b/veza-backend-api/internal/middleware/metrics_protection.go @@ -0,0 +1,99 @@ +package middleware + +import ( + "net" + "net/http" + "os" + "strings" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// MetricsProtection restricts access to /metrics endpoints. +// TASK-SEC-006: Route accessible only from internal network or via bearer token. +// +// Env vars: +// - METRICS_BEARER_TOKEN: if set, require Authorization: Bearer +// - METRICS_ALLOWED_IPS: comma-separated IPs (e.g. 127.0.0.1,10.0.0.0/8); ClientIP or X-Forwarded-For +// - METRICS_PUBLIC_IN_DEV: if "true" and APP_ENV != production, allow without auth (for local dev) +func MetricsProtection(logger *zap.Logger) gin.HandlerFunc { + bearerToken := os.Getenv("METRICS_BEARER_TOKEN") + allowedIPsRaw := os.Getenv("METRICS_ALLOWED_IPS") + publicInDev := os.Getenv("METRICS_PUBLIC_IN_DEV") == "true" + isProd := strings.ToLower(os.Getenv("APP_ENV")) == "production" || + strings.ToLower(os.Getenv("APP_ENV")) == "prod" + + var allowedIPs []string + if allowedIPsRaw != "" { + for _, s := range strings.Split(allowedIPsRaw, ",") { + s = strings.TrimSpace(s) + if s != "" { + allowedIPs = append(allowedIPs, s) + } + } + } + + return func(c *gin.Context) { + // 1. Bearer token check + if bearerToken != "" { + auth := c.GetHeader("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + token := strings.TrimPrefix(auth, "Bearer ") + if token == bearerToken { + c.Next() + return + } + } + } + + // 2. IP whitelist check + clientIP := c.ClientIP() + if forwarded := c.GetHeader("X-Forwarded-For"); forwarded != "" { + // Use first IP (original client) when behind proxy + clientIP = strings.TrimSpace(strings.Split(forwarded, ",")[0]) + } + for _, allowed := range allowedIPs { + if matchIP(clientIP, allowed) { + c.Next() + return + } + } + + // 3. Dev bypass (optional) + if !isProd && publicInDev { + c.Next() + return + } + + // 4. Deny + if logger != nil { + logger.Warn("Metrics access denied", + zap.String("client_ip", clientIP), + zap.String("path", c.Request.URL.Path), + ) + } + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "Access denied", + "code": "metrics_forbidden", + }) + } +} + +// matchIP checks if clientIP matches the allowed pattern (exact or CIDR). +func matchIP(clientIP, allowed string) bool { + allowed = strings.TrimSpace(allowed) + if allowed == "" { + return false + } + // Exact match + if clientIP == allowed { + return true + } + // CIDR match + if _, cidr, err := net.ParseCIDR(allowed); err == nil { + ip := net.ParseIP(clientIP) + return ip != nil && cidr.Contains(ip) + } + return false +} diff --git a/veza-backend-api/internal/middleware/metrics_protection_test.go b/veza-backend-api/internal/middleware/metrics_protection_test.go new file mode 100644 index 000000000..7b534cc79 --- /dev/null +++ b/veza-backend-api/internal/middleware/metrics_protection_test.go @@ -0,0 +1,95 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func TestMetricsProtection_DeniesWithoutAuth(t *testing.T) { + // Clear metrics env so access is denied (no bearer, no IP whitelist) + defer func() { + os.Unsetenv("METRICS_BEARER_TOKEN") + os.Unsetenv("METRICS_ALLOWED_IPS") + os.Unsetenv("METRICS_PUBLIC_IN_DEV") + os.Unsetenv("APP_ENV") + }() + os.Unsetenv("METRICS_BEARER_TOKEN") + os.Unsetenv("METRICS_ALLOWED_IPS") + os.Unsetenv("METRICS_PUBLIC_IN_DEV") + os.Setenv("APP_ENV", "production") + + gin.SetMode(gin.TestMode) + router := gin.New() + logger := zap.NewNop() + router.Use(MetricsProtection(logger)) + router.GET("/metrics", func(c *gin.Context) { + c.String(200, "metrics") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/metrics", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "Access denied") +} + +func TestMetricsProtection_AllowsWithBearerToken(t *testing.T) { + defer func() { + os.Unsetenv("METRICS_BEARER_TOKEN") + os.Unsetenv("METRICS_ALLOWED_IPS") + os.Unsetenv("APP_ENV") + }() + os.Setenv("METRICS_BEARER_TOKEN", "secret-token") + os.Unsetenv("METRICS_ALLOWED_IPS") + os.Setenv("APP_ENV", "production") + + gin.SetMode(gin.TestMode) + router := gin.New() + logger := zap.NewNop() + router.Use(MetricsProtection(logger)) + router.GET("/metrics", func(c *gin.Context) { + c.String(200, "metrics") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/metrics", nil) + req.Header.Set("Authorization", "Bearer secret-token") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "metrics") +} + +func TestMetricsProtection_AllowsWithWhitelistedIP(t *testing.T) { + defer func() { + os.Unsetenv("METRICS_BEARER_TOKEN") + os.Unsetenv("METRICS_ALLOWED_IPS") + os.Unsetenv("APP_ENV") + }() + os.Unsetenv("METRICS_BEARER_TOKEN") + os.Setenv("METRICS_ALLOWED_IPS", "127.0.0.1") + os.Setenv("APP_ENV", "production") + + gin.SetMode(gin.TestMode) + router := gin.New() + logger := zap.NewNop() + router.Use(MetricsProtection(logger)) + router.GET("/metrics", func(c *gin.Context) { + c.String(200, "metrics") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/metrics", nil) + req.RemoteAddr = "127.0.0.1:12345" + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "metrics") +} diff --git a/veza-backend-api/internal/middleware/rate_limiter.go b/veza-backend-api/internal/middleware/rate_limiter.go index c577d5c7d..67ddc5772 100644 --- a/veza-backend-api/internal/middleware/rate_limiter.go +++ b/veza-backend-api/internal/middleware/rate_limiter.go @@ -22,14 +22,14 @@ var ( ) // RateLimiterConfig configuration pour le rate limiter +// TASK-SEC-003: hourly limits (100 non-auth, 1000 auth) via WindowSeconds type RateLimiterConfig struct { - // Limites par IP (non authentifié) - IPRequestsPerMinute int - IPBurst int + // Limites par IP (non authentifié) et par utilisateur (auth) dans la fenêtre + IPLimit int + UserLimit int - // Limites par utilisateur authentifié - UserRequestsPerMinute int - UserBurst int + // Fenêtre en secondes (3600 = 1 heure pour TASK-SEC-003) + WindowSeconds int // Configuration Redis RedisClient *redis.Client @@ -45,15 +45,27 @@ type RateLimiter struct { // NewRateLimiter crée un nouveau rate limiter func NewRateLimiter(config *RateLimiterConfig) *RateLimiter { + window := time.Duration(config.WindowSeconds) * time.Second + if window <= 0 { + window = time.Hour + } + ipLimit := config.IPLimit + if ipLimit <= 0 { + ipLimit = 100 + } + userLimit := config.UserLimit + if userLimit <= 0 { + userLimit = 1000 + } return &RateLimiter{ config: config, ipLimiter: rate.NewLimiter( - rate.Every(time.Minute/time.Duration(config.IPRequestsPerMinute)), - config.IPBurst, + rate.Every(window/time.Duration(ipLimit)), + ipLimit, ), userLimiter: rate.NewLimiter( - rate.Every(time.Minute/time.Duration(config.UserRequestsPerMinute)), - config.UserBurst, + rate.Every(window/time.Duration(userLimit)), + userLimit, ), } } @@ -246,16 +258,21 @@ func (rl *RateLimiter) RateLimitMiddleware() gin.HandlerFunc { userIDStr = fmt.Sprintf("%v", v) } key = fmt.Sprintf("%s:user:%s", rl.config.KeyPrefix, userIDStr) - limit = rl.config.UserRequestsPerMinute + limit = rl.config.UserLimit } else { // IP non authentifiée - limite plus stricte limiter = rl.ipLimiter key = fmt.Sprintf("%s:ip:%s", rl.config.KeyPrefix, c.ClientIP()) - limit = rl.config.IPRequestsPerMinute + limit = rl.config.IPLimit + } + + windowSec := rl.config.WindowSeconds + if windowSec <= 0 { + windowSec = 3600 } // Vérifier la limite avec Redis pour persistance - allowed, remaining, err := rl.checkRedisLimit(c.Request.Context(), key, limit) + allowed, remaining, err := rl.checkRedisLimit(c.Request.Context(), key, limit, windowSec) if err != nil { // En cas d'erreur Redis, utiliser le limiter local allowed = limiter.Allow() @@ -265,12 +282,12 @@ func (rl *RateLimiter) RateLimitMiddleware() gin.HandlerFunc { // Ajouter les headers de rate limiting c.Header("X-RateLimit-Limit", strconv.Itoa(limit)) c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining)) - c.Header("X-RateLimit-Reset", strconv.FormatInt(time.Now().Add(time.Minute).Unix(), 10)) + c.Header("X-RateLimit-Reset", strconv.FormatInt(time.Now().Add(time.Duration(windowSec)*time.Second).Unix(), 10)) if !allowed { c.JSON(http.StatusTooManyRequests, gin.H{ "error": "Rate limit exceeded", - "retry_after": 60, + "retry_after": windowSec, }) c.Abort() return @@ -281,13 +298,16 @@ func (rl *RateLimiter) RateLimitMiddleware() gin.HandlerFunc { } // checkRedisLimit vérifie la limite dans Redis -func (rl *RateLimiter) checkRedisLimit(ctx context.Context, key string, limit int) (bool, int, error) { +func (rl *RateLimiter) checkRedisLimit(ctx context.Context, key string, limit int, windowSec int) (bool, int, error) { // Use in-memory fallback when Redis is not configured (e.g. integration tests) if rl.config == nil || rl.config.RedisClient == nil { return false, 0, fmt.Errorf("redis not configured") } + if windowSec <= 0 { + windowSec = 3600 + } - // Utiliser un script Lua pour l'atomicité + // Utiliser un script Lua pour l'atomicité (TASK-SEC-003: fenêtre 1h) script := ` local key = KEYS[1] local limit = tonumber(ARGV[1]) @@ -313,7 +333,7 @@ func (rl *RateLimiter) checkRedisLimit(ctx context.Context, key string, limit in script, []string{key}, limit, - 60, // 60 secondes + windowSec, ).Result() if err != nil { @@ -331,7 +351,11 @@ func (rl *RateLimiter) checkRedisLimit(ctx context.Context, key string, limit in func (rl *RateLimiter) RateLimitByIP() gin.HandlerFunc { return func(c *gin.Context) { key := fmt.Sprintf("%s:ip:%s", rl.config.KeyPrefix, c.ClientIP()) - allowed, remaining, err := rl.checkRedisLimit(c.Request.Context(), key, rl.config.IPRequestsPerMinute) + windowSec := rl.config.WindowSeconds + if windowSec <= 0 { + windowSec = 3600 + } + allowed, remaining, err := rl.checkRedisLimit(c.Request.Context(), key, rl.config.IPLimit, windowSec) if err != nil { allowed = rl.ipLimiter.Allow() @@ -339,10 +363,10 @@ func (rl *RateLimiter) RateLimitByIP() gin.HandlerFunc { } // INT-013: Standardize rate limit response format - resetTime := time.Now().Add(time.Minute).Unix() - retryAfter := 60 + resetTime := time.Now().Add(time.Duration(windowSec) * time.Second).Unix() + retryAfter := windowSec - c.Header("X-RateLimit-Limit", strconv.Itoa(rl.config.IPRequestsPerMinute)) + c.Header("X-RateLimit-Limit", strconv.Itoa(rl.config.IPLimit)) c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining)) c.Header("X-RateLimit-Reset", strconv.FormatInt(resetTime, 10)) @@ -355,12 +379,12 @@ func (rl *RateLimiter) RateLimitByIP() gin.HandlerFunc { "message": "Rate limit exceeded. Please try again later.", "details": []gin.H{ { - "field": "rate_limit", - "message": fmt.Sprintf("You have exceeded the rate limit of %d requests per minute", rl.config.IPRequestsPerMinute), + "field": "rate_limit", + "message": fmt.Sprintf("You have exceeded the rate limit of %d requests per hour", rl.config.IPLimit), }, }, "retry_after": retryAfter, - "limit": rl.config.IPRequestsPerMinute, + "limit": rl.config.IPLimit, "remaining": 0, "reset": resetTime, }, diff --git a/veza-backend-api/internal/services/upload_validator.go b/veza-backend-api/internal/services/upload_validator.go index ecf793c32..ca326331a 100644 --- a/veza-backend-api/internal/services/upload_validator.go +++ b/veza-backend-api/internal/services/upload_validator.go @@ -50,9 +50,10 @@ type UploadConfig struct { } // DefaultUploadConfig retourne la configuration par défaut +// TASK-SEC-005: MaxAudioSize 500MB (roadmap), MaxVideoSize 500MB func DefaultUploadConfig() *UploadConfig { return &UploadConfig{ - MaxAudioSize: 100 * 1024 * 1024, // 100MB + MaxAudioSize: 500 * 1024 * 1024, // 500MB (TASK-SEC-005) MaxImageSize: 10 * 1024 * 1024, // 10MB MaxVideoSize: 500 * 1024 * 1024, // 500MB diff --git a/veza-backend-api/internal/services/upload_validator_test.go b/veza-backend-api/internal/services/upload_validator_test.go index a03c72f38..b3f584a16 100644 --- a/veza-backend-api/internal/services/upload_validator_test.go +++ b/veza-backend-api/internal/services/upload_validator_test.go @@ -253,3 +253,11 @@ func TestUploadValidator_SVGRejected_SEC026(t *testing.T) { assert.False(t, result2.Valid, "SVG with tag should be rejected") assert.Contains(t, result2.Error, "invalid image file signature", "SVG must be rejected by magic bytes") } + +// TestDefaultUploadConfig_MaxAudioSize vérifie que la limite audio est 500MB (TASK-SEC-005) +func TestDefaultUploadConfig_MaxAudioSize(t *testing.T) { + cfg := DefaultUploadConfig() + assert.Equal(t, int64(500*1024*1024), cfg.MaxAudioSize, "MaxAudioSize should be 500MB per TASK-SEC-005") + assert.Equal(t, int64(500*1024*1024), cfg.MaxVideoSize, "MaxVideoSize should be 500MB") + assert.Equal(t, int64(10*1024*1024), cfg.MaxImageSize, "MaxImageSize should be 10MB") +}