diff --git a/server/routes/analytics.go b/server/routes/analytics.go index af7ed759..46e7b7e0 100644 --- a/server/routes/analytics.go +++ b/server/routes/analytics.go @@ -3,6 +3,7 @@ package routes import ( "context" + "crypto/subtle" "fmt" "net/http" "os" @@ -12,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "go.mongodb.org/mongo-driver/bson" "schej.it/server/db" + "schej.it/server/logger" "schej.it/server/models" "schej.it/server/slackbot" ) @@ -21,9 +23,24 @@ func AnalyticsBasicAuth() gin.HandlerFunc { return func(c *gin.Context) { analyticsUsername := os.Getenv("ANALYTICS_USERNAME") analyticsPassword := os.Getenv("ANALYTICS_PASSWORD") + + // Fail closed if credentials aren't configured. Otherwise empty env + // vars combined with the old `user != "" || pass != ""` style check + // would let a request with empty credentials authenticate, exposing + // these admin endpoints (user lookup, manual upgrade/downgrade). + if analyticsUsername == "" || analyticsPassword == "" { + logger.StdErr.Println("ANALYTICS_USERNAME/ANALYTICS_PASSWORD not configured; refusing analytics request") + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "Analytics auth not configured"}) + return + } + user, pass, hasAuth := c.Request.BasicAuth() - if !hasAuth || user != analyticsUsername || pass != analyticsPassword { + // Constant-time comparison to avoid leaking the credentials via timing. + userMatch := subtle.ConstantTimeCompare([]byte(user), []byte(analyticsUsername)) == 1 + passMatch := subtle.ConstantTimeCompare([]byte(pass), []byte(analyticsPassword)) == 1 + + if !hasAuth || !userMatch || !passMatch { c.Header("WWW-Authenticate", `Basic realm="Restricted"`) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return