v0.9.2
This commit is contained in:
parent
2df921abd5
commit
b6c004319c
12 changed files with 375 additions and 62 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
99
veza-backend-api/internal/middleware/metrics_protection.go
Normal file
99
veza-backend-api/internal/middleware/metrics_protection.go
Normal 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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue