feat(security): add global and per-IP DDoS rate limiting (1000/s, 100/s)

SEC1-04: Redis sliding window 1s, excluded paths (health, swagger, auth)
This commit is contained in:
senke 2026-03-03 09:25:08 +01:00
parent 6a82959a96
commit 354c747cce
2 changed files with 126 additions and 0 deletions

View file

@ -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 {

View file

@ -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) {