From d418ae2b54e69ae140ea9afcd60d8378e15f9b6c Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Fri, 29 May 2026 21:08:01 +0300 Subject: [PATCH] fix: add panic recovery, rate limiting, timing-safe CI token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Recovery middleware (catches panics, returns 500, logs stack trace) - Add RateLimiter to middleware chain (30 req/min, burst 60 per IP) - Fix CI token comparison with subtle.ConstantTimeCompare (timing attack) - Middleware chain: Recovery → Logging → RateLimit → CORS → mux Co-Authored-By: Claude Opus 4.6 --- cmd/server/main.go | 5 ++++- internal/admin/admin.go | 3 ++- internal/middleware/middleware.go | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index c3a3e25..e7fdbe3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -67,10 +67,13 @@ func main() { templatesHandler := templates.NewHandler(db, cfg) templatesHandler.RegisterRoutes(mux) - // Wrapper chain: Logging → CORS → mux. + // Wrapper chain: Recovery → Logging → RateLimit → CORS → mux. + // Recovery must be outermost so it catches panics in all inner layers. var handler http.Handler = mux handler = middleware.CORS(handler) + handler = middleware.NewRateLimiter(30, time.Minute, 60).Limit(handler) handler = middleware.Logging(handler) + handler = middleware.Recovery(handler) addr := ":" + itoa(cfg.Port) srv := &http.Server{ diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 7b33e55..80fe8ef 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -7,6 +7,7 @@ import ( "context" "crypto/sha1" "crypto/sha256" + "crypto/subtle" "encoding/hex" "encoding/json" "fmt" @@ -85,7 +86,7 @@ func (h *Handler) auth(next http.HandlerFunc) http.HandlerFunc { func (h *Handler) ciToken(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("X-CI-Token") - if token == "" || token != h.cfg.CIsecret { + if token == "" || subtle.ConstantTimeCompare([]byte(token), []byte(h.cfg.CIsecret)) != 1 { writeError(w, http.StatusForbidden, "Invalid CI token") return } diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index cd431c5..03ba88e 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -4,6 +4,7 @@ package middleware import ( "log" "net/http" + "runtime/debug" "strings" "sync" "time" @@ -39,6 +40,20 @@ func Logging(next http.Handler) http.Handler { }) } +// Recovery catches panics in downstream handlers and returns 500. +// Logs the stack trace for debugging. +func Recovery(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + log.Printf("PANIC: %v\n%s", rec, debug.Stack()) + http.Error(w, `{"error":"Internal Server Error"}`, http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} + // statusWriter wraps http.ResponseWriter to capture the status code. type statusWriter struct { http.ResponseWriter