diff --git a/TODO.md b/TODO.md index c62db67..dab3310 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,14 @@ P1: implement blocked networks configuration to extend SSRF protection # Completed Steps +- 2026-09-21 http.Server hardening (closes #92): added + `HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and + `HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the + existing timeouts and wired them onto the server; added a `LimitBody` + middleware capping the two form POST bodies (`POST /`, `POST /generate`) + at `MaxFormBytes` (1 MiB) and returning 413, applied ahead of the CSRF + middleware so an oversized body is refused as 413 rather than being read + as a missing CSRF token (403); left `WriteTimeout` at 60s unchanged - 2026-08-07 update golangci-lint to v2.12.2 with the canonical `.golangci.yml` (v2 schema, `default: all` minus six disabled linters, `lll` 88, tests included): bumped the pinned diff --git a/internal/handlers/bodylimit.go b/internal/handlers/bodylimit.go new file mode 100644 index 0000000..a2c9adc --- /dev/null +++ b/internal/handlers/bodylimit.go @@ -0,0 +1,45 @@ +package handlers + +import ( + "errors" + "net/http" +) + +// MaxFormBytes bounds the request body accepted on the HTML form POST +// routes (POST / and POST /generate). The forms carry a handful of short +// fields, so 1 MiB is generous while making the bound explicit rather than +// resting on ParseForm's incidental 10 MB cap. +const MaxFormBytes = 1 << 20 // 1 MiB + +// LimitBody returns middleware that caps the request body on POST requests +// at maxBytes and rejects an oversized body with 413 Request Entity Too +// Large. +// +// It parses the form here, before the CSRF middleware reads the token from +// it. The CSRF middleware reads the token with PostFormValue, which +// swallows a parse error, so if the body were only capped there an +// oversized body would read as a missing token and be refused as 403. By +// parsing under the cap first, an oversized body is refused as 413. A +// successful parse is cached on the request, so the CSRF check and the +// handler reuse it rather than reading the body again. +func (s *Handlers) LimitBody(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.Body = http.MaxBytesReader(w, r.Body, maxBytes) + + err := r.ParseForm() + + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + http.Error(w, "Request body too large", + http.StatusRequestEntityTooLarge) + + return + } + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 1fe054e..3b7dfc5 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -8,6 +8,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/prometheus/client_golang/prometheus/promhttp" + "sneak.berlin/go/pixa/internal/handlers" "sneak.berlin/go/pixa/internal/static" ) @@ -46,8 +47,10 @@ func (s *Server) SetupRoutes() { // Login/generator UI. The form routes carry CSRF protection; the // token cookie is independent of the session cookie, so it also - // covers the login POST, where no session exists yet. + // covers the login POST, where no session exists yet. LimitBody caps + // the POST body ahead of CSRF, which reads its token from that body. s.router.Group(func(r chi.Router) { + r.Use(s.h.LimitBody(handlers.MaxFormBytes)) r.Use(s.h.CSRF()) r.Get("/", s.h.HandleRoot()) r.Post("/", s.h.HandleRoot())