diff options
| author | grm <grm@eyesin.space> | 2026-09-18 13:46:33 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-18 13:46:33 +0300 |
| commit | 254733b0566830a46e5a44c2d3127f57bfaceb65 (patch) | |
| tree | 598beea9e6e19fc43453445efaa6593b3112f9e2 /internal | |
| parent | f8d90e3d80ec798689be5fdf792e6c1401ad748f (diff) | |
| download | blogspace-254733b0566830a46e5a44c2d3127f57bfaceb65.tar.gz blogspace-254733b0566830a46e5a44c2d3127f57bfaceb65.tar.bz2 blogspace-254733b0566830a46e5a44c2d3127f57bfaceb65.zip | |
Security: Cap the login body and log failed logins
POST /webadmin is the one form outside guardPOST, so nothing bounded
its body: a multipart login could park 32 MB in memory or temp files
per request. It now reads at most 64 KB. Wrong passwords are logged
with the username and client address so an attack shows up in the log
(fail2ban can read it) instead of being invisible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/web/handlers_auth.go | 11 | ||||
| -rw-r--r-- | internal/web/web_test.go | 13 |
2 files changed, 24 insertions, 0 deletions
diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go index 1545a4e..69b7b98 100644 --- a/internal/web/handlers_auth.go +++ b/internal/web/handlers_auth.go @@ -44,7 +44,16 @@ func (s *Server) handleWebadminRedirect(w http.ResponseWriter, r *http.Request) http.Redirect(w, r, s.cfg.RootURL()+"/webadmin?next="+urlQuery("/b/"+sub+"/"), http.StatusSeeOther) } +// maxLoginBody is all a login form needs; it is the one POST guardPOST does +// not cap, so it caps itself. +const maxLoginBody = 64 << 10 + func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxLoginBody) + if err := r.ParseForm(); err != nil { + s.plainError(w, http.StatusBadRequest, s.tr(r, "Could not read the form.")) + return + } username := strings.TrimSpace(r.FormValue("username")) password := r.FormValue("password") next := safeNext(r.FormValue("next")) @@ -64,10 +73,12 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } auth.CheckPassword("$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinval", password) // constant-ish time + logf("login failed for %q from %s", username, clientIP(r)) fail() return } if u.Disabled || !auth.CheckPassword(u.PasswordHash, password) { + logf("login failed for %q from %s", username, clientIP(r)) fail() return } diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 6324443..49915f4 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -962,3 +962,16 @@ func TestLoginThrottled(t *testing.T) { t.Errorf("got %d, want 429", rec.Code) } } + +// The login form is the one POST outside guardPOST; it caps its own body. +func TestLoginBodyCapped(t *testing.T) { + s := NewServer(&config.Config{BaseDomain: "example.com", JWTSecret: []byte("x")}, nil) + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/webadmin", strings.NewReader("username=a&password="+strings.Repeat("x", maxLoginBody))) + req.Host = "example.com" + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("got %d, want 400", rec.Code) + } +} |
