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