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) + } +}