veza/veza-backend-api/internal/middleware/metrics_protection.go
senke c0e2fe2e12 fix(v0.12.6.1): remediate remaining 15 MEDIUM + LOW pentest findings
MEDIUM-002: Remove manual X-Forwarded-For parsing in metrics_protection.go,
  use c.ClientIP() only (respects SetTrustedProxies)
MEDIUM-003: Pin ClamAV Docker image to 1.4 across all compose files
MEDIUM-004: Add clampLimit(100) to 15+ handlers that parsed limit directly
MEDIUM-006: Remove unsafe-eval from CSP script-src on Swagger routes
MEDIUM-007: Pin all GitHub Actions to SHA in 11 workflow files
MEDIUM-008: Replace rabbitmq:3-management-alpine with rabbitmq:3-alpine in prod
MEDIUM-009: Add trial-already-used check in subscription service
MEDIUM-010: Add 60s periodic token re-validation to WebSocket connections
MEDIUM-011: Mask email in auth handler logs with maskEmail() helper
MEDIUM-012: Add k-anonymity threshold (k=5) to playback analytics stats
LOW-001: Align frontend password policy to 12 chars (matching backend)
LOW-003: Replace deprecated dotenv with dotenvy crate in Rust stream server
LOW-004: Enable xpack.security in Elasticsearch dev/local compose files
LOW-005: Accept context.Context in CleanupExpiredSessions instead of Background()
LOW-002: Noted — Hyperswitch version update deferred (requires payment integration tests)

29/30 findings remediated. 1 noted (LOW-002).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:13:38 +01:00

97 lines
2.5 KiB
Go

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
// SECURITY(MEDIUM-002): Use only c.ClientIP() which respects Engine.SetTrustedProxies.
// Manual X-Forwarded-For parsing is spoofable and bypasses Gin's trusted proxy validation.
clientIP := c.ClientIP()
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
}