From b2c6f00f56f9125e0e0713ee2d5cd2e4453a729a Mon Sep 17 00:00:00 2001 From: Alex Howells Date: Sun, 15 Mar 2026 09:35:11 -0700 Subject: [PATCH] fix(security): require authentication on /metrics endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Prometheus metrics endpoint is the only route on the device with no authentication. Every other route is either public-by-design or behind protectedMiddleware (cookie) or basicAuthProtectedMiddleware (basic auth). Add metricsAuthMiddleware that accepts either the session cookie (browser access) or HTTP basic auth (Prometheus scrape configs). When localAuthMode is noPassword, all requests pass through — consistent with every other protected endpoint. No config toggle, no UI changes. The endpoint remains always available. Existing no-password setups are unaffected. Password-mode setups need to add basic_auth to their Prometheus scrape config. Alternative approach to #299 that addresses the security concern without disabling the endpoint. --- web.go | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/web.go b/web.go index 64cb46b94..551ff3903 100644 --- a/web.go +++ b/web.go @@ -142,7 +142,9 @@ func setupRouter() *gin.Engine { r.POST("/device/setup", handleSetup) // A Prometheus metrics endpoint. - r.GET("/metrics", gin.WrapH(promhttp.Handler())) + // Requires auth (cookie or basic auth) when password mode is enabled, + // open when localAuthMode is noPassword (consistent with other endpoints). + r.GET("/metrics", metricsAuthMiddleware(), gin.WrapH(promhttp.Handler())) // Developer mode protected routes developerModeRouter := r.Group("/developer/") @@ -553,6 +555,39 @@ func sendErrorJsonThenAbort(c *gin.Context, status int, message string) { c.Abort() } +// metricsAuthMiddleware authenticates the /metrics endpoint using either the +// session cookie or HTTP basic auth (for Prometheus scrape configs). When +// localAuthMode is noPassword, all requests are allowed through — consistent +// with every other endpoint on the device. +func metricsAuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if config.LocalAuthMode == "noPassword" { + c.Next() + return + } + + // Try cookie auth first (browser access) + authToken, err := c.Cookie("authToken") + if err == nil && authToken == config.LocalAuthToken && authToken != "" { + c.Next() + return + } + + // Fall back to basic auth (Prometheus scraper) + _, password, ok := c.Request.BasicAuth() + if ok { + if err := bcrypt.CompareHashAndPassword([]byte(config.HashedPassword), []byte(password)); err == nil { + c.Next() + return + } + } + + c.Header("WWW-Authenticate", `Basic realm="JetKVM Metrics"`) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + c.Abort() + } +} + func basicAuthProtectedMiddleware(requireDeveloperMode bool) gin.HandlerFunc { return func(c *gin.Context) { if requireDeveloperMode {