From 64fee5b7fc959f79574f9c41453b3eca9094f7cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 16:13:00 +0000 Subject: [PATCH] fix(security): allow /metrics to require a bearer token The Prometheus endpoint was registered with no authentication. It exposes Go runtime stats, DB pool stats, per-path request counters and auth attempt/failure counters -- useful reconnaissance for anyone who can reach the port, and the user/registration counters make it a slow enumeration oracle. Live-confirmed: GET /metrics with no credentials returned 200 with 340 metric lines. METRICS_TOKEN now gates the endpoint behind a bearer token, compared in constant time so response timing cannot leak it prefix-wise. Both 'Bearer ' and a raw token are accepted, since Prometheus' bearer_token_file sends the former while simple scrape configs often send the latter. When METRICS_TOKEN is unset the endpoint stays open and a startup warning names the setting. That keeps existing deployments working: the intended topology scrapes over the internal Docker network with the API port unpublished, where the endpoint is already unreachable from outside. Failing closed would silently break those scrapes on upgrade, so this is opt-in with a loud default. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GukWfyJMY28qv2CJjxFvKF --- .env.example | 8 +++ cmd/api/main.go | 20 ++++++- internal/api/middleware/metrics_auth.go | 36 ++++++++++++ internal/api/middleware/metrics_auth_test.go | 61 ++++++++++++++++++++ 4 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 internal/api/middleware/metrics_auth.go create mode 100644 internal/api/middleware/metrics_auth_test.go diff --git a/.env.example b/.env.example index f86a6aa..f503bad 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,14 @@ REFRESH_EXPIRES_IN=7d # ----------------------------------------------------------------------------- CORS_ORIGIN=http://localhost:5173 +# ----------------------------------------------------------------------------- +# Metrics +# ----------------------------------------------------------------------------- +# Bearer token required to scrape /metrics. Leave unset only if the API port is +# unreachable outside the scrape network -- /metrics exposes request, DB and +# auth counters. Prometheus: use bearer_token_file in the scrape config. +# METRICS_TOKEN= + # ----------------------------------------------------------------------------- # Admin # ----------------------------------------------------------------------------- diff --git a/cmd/api/main.go b/cmd/api/main.go index 88a4758..ceef3d5 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -355,7 +355,15 @@ func main() { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) - // Prometheus metrics endpoint (no auth required, alongside /health) + // Prometheus metrics endpoint. + // + // Previously unauthenticated. It exposes Go runtime stats, DB pool stats, + // per-path request counters and auth attempt/failure counters -- useful + // reconnaissance and a slow user-count oracle for anyone who can reach the + // port. Now gated on a bearer token when METRICS_TOKEN is set; when it is + // not set the endpoint stays open, which is correct for the intended + // topology (scraped over the internal Docker network, API port not + // published) but is warned about at startup so it is a deliberate choice. if metricsEnabled { appVersion := os.Getenv("APP_VERSION") if appVersion == "" { @@ -364,7 +372,15 @@ func main() { middleware.RegisterAppMetrics(appVersion, startedAt) prometheus.MustRegister(middleware.NewDBStatsCollector(db)) - router.GET("/metrics", gin.WrapH(promhttp.Handler())) + metricsToken := os.Getenv("METRICS_TOKEN") + if metricsToken == "" { + slog.Warn("METRICS_TOKEN not set — /metrics is unauthenticated; " + + "set it, or ensure the API port is not reachable outside the scrape network") + router.GET("/metrics", gin.WrapH(promhttp.Handler())) + } else { + router.GET("/metrics", middleware.MetricsAuthMiddleware(metricsToken), gin.WrapH(promhttp.Handler())) + slog.Info("Prometheus metrics require a bearer token") + } slog.Info("Prometheus metrics enabled at /metrics") } diff --git a/internal/api/middleware/metrics_auth.go b/internal/api/middleware/metrics_auth.go new file mode 100644 index 0000000..427770a --- /dev/null +++ b/internal/api/middleware/metrics_auth.go @@ -0,0 +1,36 @@ +package middleware + +import ( + "crypto/subtle" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// MetricsAuthMiddleware guards the Prometheus endpoint with a shared bearer +// token. +// +// /metrics exposes Go runtime stats, DB pool stats, per-path request counters +// and auth attempt/failure counters. That is useful reconnaissance for anyone +// who can reach the port, and the user/registration counters make it a slow +// enumeration oracle, so it should not be world-readable. +// +// The comparison is constant-time: a naive == leaks the token prefix-wise to an +// attacker who can measure response timing across many requests. +func MetricsAuthMiddleware(token string) gin.HandlerFunc { + expected := []byte(token) + return func(c *gin.Context) { + presented := c.GetHeader("Authorization") + if after, ok := strings.CutPrefix(presented, "Bearer "); ok { + presented = after + } + if subtle.ConstantTimeCompare([]byte(presented), expected) != 1 { + // Deliberately terse: no hint about whether a token was presented + // or merely wrong. + c.AbortWithStatus(http.StatusUnauthorized) + return + } + c.Next() + } +} diff --git a/internal/api/middleware/metrics_auth_test.go b/internal/api/middleware/metrics_auth_test.go new file mode 100644 index 0000000..3218d3c --- /dev/null +++ b/internal/api/middleware/metrics_auth_test.go @@ -0,0 +1,61 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func metricsRouter(token string) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/metrics", MetricsAuthMiddleware(token), func(c *gin.Context) { + c.String(http.StatusOK, "go_goroutines 12") + }) + return r +} + +func getMetrics(r *gin.Engine, authHeader string) int { + req := httptest.NewRequest("GET", "/metrics", nil) + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w.Code +} + +func TestMetricsAuth(t *testing.T) { + r := metricsRouter("s3cret-scrape-token") + + tests := []struct { + name, header string + want int + }{ + {"no header", "", http.StatusUnauthorized}, + {"wrong token", "Bearer nope", http.StatusUnauthorized}, + {"empty bearer", "Bearer ", http.StatusUnauthorized}, + {"token prefix only", "Bearer s3cret", http.StatusUnauthorized}, + {"correct bearer", "Bearer s3cret-scrape-token", http.StatusOK}, + // Prometheus' bearer_token_file sends the Bearer form, but accept a + // raw token too so simple scrape configs work. + {"raw token", "s3cret-scrape-token", http.StatusOK}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := getMetrics(r, tc.header); got != tc.want { + t.Errorf("status = %d, want %d", got, tc.want) + } + }) + } +} + +// A longer presented token must not be accepted just because it shares a prefix. +func TestMetricsAuth_RejectsPrefixExtension(t *testing.T) { + r := metricsRouter("abc") + if got := getMetrics(r, "Bearer abcdef"); got != http.StatusUnauthorized { + t.Errorf("prefix-extended token accepted: %d", got) + } +}