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:
parent
6a82959a96
commit
354c747cce
2 changed files with 126 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue