From 0489d9916f1e786b7b8679c2a3d28bf12b8d3158 Mon Sep 17 00:00:00 2001 From: clawbot Date: Thu, 5 Mar 2026 02:53:45 -0800 Subject: [PATCH] security: add headers middleware, session regeneration, and body size limits - Add SecurityHeaders middleware applied globally: HSTS, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, and Permissions-Policy headers on every response. - Add session regeneration (Regenerate method) after successful login to prevent session fixation attacks. Old session is destroyed and a new ID is issued. - Add MaxBodySize middleware using http.MaxBytesReader to limit POST/PUT/PATCH request bodies to 1 MB on all form endpoints (/pages, /sources, /source/*). Closes #34, closes #38, closes #39 --- internal/handlers/auth.go | 13 +++++++-- internal/middleware/middleware.go | 32 +++++++++++++++++++++ internal/server/routes.go | 10 +++++++ internal/session/session.go | 47 +++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index e0b23bc..ba916c3 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -78,14 +78,23 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc { return } - // Create session - sess, err := h.session.Get(r) + // Get the current session (may be pre-existing / attacker-set) + oldSess, err := h.session.Get(r) if err != nil { h.log.Error("failed to get session", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } + // Regenerate the session to prevent session fixation attacks. + // This destroys the old session ID and creates a new one. + sess, err := h.session.Regenerate(r, w, oldSess) + if err != nil { + h.log.Error("failed to regenerate session", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + // Set user in session h.session.SetUser(sess, user.ID, user.Username) diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 4f96a63..2f82ffe 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -171,3 +171,35 @@ func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler { }, ) } + +// SecurityHeaders returns middleware that sets production security headers +// on every response: HSTS, X-Content-Type-Options, X-Frame-Options, CSP, +// Referrer-Policy, and Permissions-Policy. +func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + next.ServeHTTP(w, r) + }) + } +} + +// MaxBodySize returns middleware that limits the request body size for POST +// requests. If the body exceeds the given limit in bytes, the server returns +// 413 Request Entity Too Large. This prevents clients from sending arbitrarily +// large form bodies. +func (s *Middleware) MaxBodySize(maxBytes int64) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch { + r.Body = http.MaxBytesReader(w, r.Body, maxBytes) + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 347b976..c357614 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -11,12 +11,18 @@ import ( "sneak.berlin/go/webhooker/static" ) +// maxFormBodySize is the maximum allowed request body size (in bytes) for +// form POST endpoints. 1 MB is generous for any form submission while +// preventing abuse from oversized payloads. +const maxFormBodySize int64 = 1 * 1024 * 1024 // 1 MB + func (s *Server) SetupRoutes() { s.router = chi.NewRouter() // Global middleware stack — applied to every request. s.router.Use(middleware.Recoverer) s.router.Use(middleware.RequestID) + s.router.Use(s.mw.SecurityHeaders()) s.router.Use(s.mw.Logging()) // Metrics middleware (only if credentials are configured) @@ -60,6 +66,8 @@ func (s *Server) SetupRoutes() { // pages that are rendered server-side s.router.Route("/pages", func(r chi.Router) { + r.Use(s.mw.MaxBodySize(maxFormBodySize)) + // Login page (no auth required) r.Get("/login", s.h.HandleLoginPage()) r.Post("/login", s.h.HandleLoginSubmit()) @@ -76,6 +84,7 @@ func (s *Server) SetupRoutes() { // Webhook management routes (require authentication) s.router.Route("/sources", func(r chi.Router) { r.Use(s.mw.RequireAuth()) + r.Use(s.mw.MaxBodySize(maxFormBodySize)) r.Get("/", s.h.HandleSourceList()) // List all webhooks r.Get("/new", s.h.HandleSourceCreate()) // Show create form r.Post("/new", s.h.HandleSourceCreateSubmit()) // Handle create submission @@ -83,6 +92,7 @@ func (s *Server) SetupRoutes() { s.router.Route("/source/{sourceID}", func(r chi.Router) { r.Use(s.mw.RequireAuth()) + r.Use(s.mw.MaxBodySize(maxFormBodySize)) r.Get("/", s.h.HandleSourceDetail()) // View webhook details r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission diff --git a/internal/session/session.go b/internal/session/session.go index abb9e47..73a89ba 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -135,3 +135,50 @@ func (s *Session) Destroy(sess *sessions.Session) { sess.Options.MaxAge = -1 s.ClearUser(sess) } + +// Regenerate creates a new session with the same values but a fresh ID. +// The old session is destroyed (MaxAge = -1) and saved, then a new session +// is created. This prevents session fixation attacks by ensuring the +// session ID changes after privilege escalation (e.g. login). +func (s *Session) Regenerate(r *http.Request, w http.ResponseWriter, oldSess *sessions.Session) (*sessions.Session, error) { + // Copy the values from the old session + oldValues := make(map[interface{}]interface{}) + for k, v := range oldSess.Values { + oldValues[k] = v + } + + // Destroy the old session + oldSess.Options.MaxAge = -1 + s.ClearUser(oldSess) + if err := oldSess.Save(r, w); err != nil { + return nil, fmt.Errorf("failed to destroy old session: %w", err) + } + + // Create a new session (gorilla/sessions generates a new ID) + newSess, err := s.store.New(r, SessionName) + if err != nil { + // store.New may return an error alongside a new empty session + // if the old cookie is now invalid. That is expected after we + // destroyed it above. Only fail on a nil session. + if newSess == nil { + return nil, fmt.Errorf("failed to create new session: %w", err) + } + } + + // Restore the copied values into the new session + for k, v := range oldValues { + newSess.Values[k] = v + } + + // Apply the standard session options (the destroyed old session had + // MaxAge = -1, which store.New might inherit from the cookie). + newSess.Options = &sessions.Options{ + Path: "/", + MaxAge: 86400 * 7, + HttpOnly: true, + Secure: !s.config.IsDev(), + SameSite: http.SameSiteLaxMode, + } + + return newSess, nil +}