v0.9.2
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s

This commit is contained in:
senke 2026-03-05 19:27:34 +01:00
parent 2df921abd5
commit b6c004319c
12 changed files with 375 additions and 62 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <token>
// - 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
}

View file

@ -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")
}

View file

@ -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,
},

View file

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

View file

@ -253,3 +253,11 @@ func TestUploadValidator_SVGRejected_SEC026(t *testing.T) {
assert.False(t, result2.Valid, "SVG with <svg> 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")
}