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>
97 lines
2.5 KiB
Go
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
|
|
}
|