diff --git a/veza-backend-api/internal/api/router.go b/veza-backend-api/internal/api/router.go index 97eb4928a..a16d9a67e 100644 --- a/veza-backend-api/internal/api/router.go +++ b/veza-backend-api/internal/api/router.go @@ -222,6 +222,11 @@ func (r *APIRouter) Setup(router *gin.Engine) error { // MOD-P0-003: Removed duplicate timeout middleware registration router.Use(middleware.Timeout(r.config.HandlerTimeout)) + // v0.803 SEC1-04: DDoS rate limiting (1000 req/s global, 100 req/s per-IP) + if r.config != nil && r.config.RedisClient != nil { + router.Use(middleware.DDoSRateLimitMiddleware(r.config.RedisClient)) + } + // Rate limiting via config.RateLimiter si disponible, sinon utiliser SimpleRateLimiter // Toujours actif (A04) — limites assouplies en dev via config if r.config != nil { diff --git a/veza-backend-api/internal/middleware/rate_limiter.go b/veza-backend-api/internal/middleware/rate_limiter.go index 28b7ac72f..c577d5c7d 100644 --- a/veza-backend-api/internal/middleware/rate_limiter.go +++ b/veza-backend-api/internal/middleware/rate_limiter.go @@ -87,6 +87,127 @@ func isExcludedPathRedis(path string) bool { return false } +// DDoS rate limit constants (SEC1-04): global 1000 req/s, per-IP 100 req/s +const ( + ddosGlobalLimit = 1000 + ddosPerIPLimit = 100 + ddosWindowSeconds = 1 +) + +// ddosRateLimitFallback stores in-memory limiters when Redis fails (fail-secure) +var ( + ddosGlobalFallback *rate.Limiter + ddosFallbackMu sync.Mutex + ddosPerIPFallbackMap sync.Map +) + +// DDoSRateLimitMiddleware applies SEC1-04 DDoS protection: global 1000 req/s, per-IP 100 req/s. +// Uses 1-second sliding window. Excludes health, swagger, auth critical paths. +// Must run before main RateLimitMiddleware. When Redis is nil, uses in-memory fallback. +func DDoSRateLimitMiddleware(redisClient *redis.Client) gin.HandlerFunc { + return func(c *gin.Context) { + if isExcludedPathRedis(c.Request.URL.Path) { + c.Next() + return + } + if os.Getenv("DISABLE_RATE_LIMIT_FOR_TESTS") == "true" { + c.Next() + return + } + + ctx := c.Request.Context() + ip := c.ClientIP() + + // Check global limit + globalKey := "rate:ddos:global" + globalAllowed, _, err := checkRedisLimit1s(ctx, redisClient, globalKey, ddosGlobalLimit) + if err != nil { + globalAllowed = getDDoSFallbackLimiter(globalKey, ddosGlobalLimit).Allow() + } + if !globalAllowed { + c.JSON(http.StatusTooManyRequests, gin.H{ + "error": "Global rate limit exceeded (DDoS protection)", + "retry_after": ddosWindowSeconds, + }) + c.Abort() + return + } + + // Check per-IP limit + ipKey := "rate:ddos:ip:" + ip + ipAllowed, remaining, err := checkRedisLimit1s(ctx, redisClient, ipKey, ddosPerIPLimit) + if err != nil { + ipAllowed = getDDoSPerIPFallbackLimiter(ipKey).Allow() + remaining = 0 + } + c.Header("X-RateLimit-Limit", strconv.Itoa(ddosPerIPLimit)) + c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining)) + if !ipAllowed { + c.Header("Retry-After", strconv.Itoa(ddosWindowSeconds)) + c.JSON(http.StatusTooManyRequests, gin.H{ + "error": "Rate limit exceeded. Please try again in a moment.", + "retry_after": ddosWindowSeconds, + }) + c.Abort() + return + } + + c.Next() + } +} + +// checkRedisLimit1s uses Redis INCR with 1-second window (returns allowed, remaining, error) +func checkRedisLimit1s(ctx context.Context, redisClient *redis.Client, key string, limit int) (bool, int, error) { + if redisClient == nil { + return false, 0, fmt.Errorf("redis not configured") + } + script := ` + local key = KEYS[1] + local limit = tonumber(ARGV[1]) + local window = tonumber(ARGV[2]) + local current = redis.call('GET', key) + if current == false then + redis.call('SET', key, 1, 'EX', window) + return {1, limit - 1} + end + local count = tonumber(current) + if count < limit then + redis.call('INCR', key) + return {1, limit - count - 1} + else + return {0, 0} + end + ` + result, err := redisClient.Eval(ctx, script, []string{key}, limit, ddosWindowSeconds).Result() + if err != nil { + return false, 0, err + } + results := result.([]interface{}) + allowed := results[0].(int64) == 1 + remaining := int(results[1].(int64)) + return allowed, remaining, nil +} + +func getDDoSFallbackLimiter(key string, limit int) *rate.Limiter { + ddosFallbackMu.Lock() + defer ddosFallbackMu.Unlock() + if ddosGlobalFallback == nil { + ddosGlobalFallback = rate.NewLimiter(rate.Every(time.Second/time.Duration(limit)), limit) + } + return ddosGlobalFallback +} + +func getDDoSPerIPFallbackLimiter(key string) *rate.Limiter { + if v, ok := ddosPerIPFallbackMap.Load(key); ok { + return v.(*rate.Limiter) + } + limiter := rate.NewLimiter(rate.Every(time.Second/time.Duration(ddosPerIPLimit)), ddosPerIPLimit) + if v, loaded := ddosPerIPFallbackMap.LoadOrStore(key, limiter); loaded { + return v.(*rate.Limiter) + } + return limiter +} + // RateLimitMiddleware middleware principal de rate limiting func (rl *RateLimiter) RateLimitMiddleware() gin.HandlerFunc { return func(c *gin.Context) {