From 5ec785f7a60b44c803a1e259cc3fd1703966804a Mon Sep 17 00:00:00 2001 From: Tony Xin Date: Fri, 29 May 2026 13:12:38 -0400 Subject: [PATCH] fix(security): harden analytics Basic Auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why this change is needed `AnalyticsBasicAuth` protects admin endpoints that can look up any user by email and manually upgrade/downgrade accounts. Two issues weakened it: 1. **Fail-open when unconfigured.** The check compared the request credentials directly against the env vars. If `ANALYTICS_USERNAME`/`ANALYTICS_PASSWORD` were unset (empty), a request presenting empty Basic Auth credentials would match and be granted access — turning a misconfiguration into an open admin API. 2. **Non-constant-time comparison.** Using `==` on the credentials can leak their length/contents through timing differences. ## What this does - Refuses the request with `503` if either credential env var is unset, so the endpoints fail closed instead of open. - Compares username and password with `crypto/subtle.ConstantTimeCompare`. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/routes/analytics.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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