Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# -----------------------------------------------------------------------------
Expand Down
20 changes: 18 additions & 2 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand All @@ -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")
}

Expand Down
36 changes: 36 additions & 0 deletions internal/api/middleware/metrics_auth.go
Original file line number Diff line number Diff line change
@@ -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()
}
}
61 changes: 61 additions & 0 deletions internal/api/middleware/metrics_auth_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading