2026-03-05 18:27:34 +00:00
|
|
|
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
|
2026-03-12 05:13:38 +00:00
|
|
|
// 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.
|
2026-03-05 18:27:34 +00:00
|
|
|
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
|
|
|
|
|
}
|