diff --git a/internal/auth/web.go b/internal/auth/web.go index 3c13372..3e94301 100644 --- a/internal/auth/web.go +++ b/internal/auth/web.go @@ -80,9 +80,15 @@ func (s *Handlers) credentialRoutes(r chi.Router) { authLimit := web.NewRateLimiter(10, 6*time.Second) r.With(authLimit.Middleware).Post("/login/password", s.Auth.LoginPassword) r.With(authLimit.Middleware).Post("/signup/password", s.Auth.SignupPassword) - r.Post("/api/register/begin", s.Auth.RegisterBegin) + // The user-initiated WebAuthn "begin" ceremonies share the same per-IP + // credential-attempt bucket: register/begin persists an account row (unbounded + // spam would flood the users table and squat usernames) and login/begin is a + // username oracle. The finish steps require an in-progress ceremony, and + // discoverable/begin auto-fires on page load (passive autofill) and writes + // nothing, so those stay unthrottled. + r.With(authLimit.Middleware).Post("/api/register/begin", s.Auth.RegisterBegin) r.Post("/api/register/finish", s.Auth.RegisterFinish) - r.Post("/api/login/begin", s.Auth.LoginBegin) + r.With(authLimit.Middleware).Post("/api/login/begin", s.Auth.LoginBegin) r.Post("/api/login/finish", s.Auth.LoginFinish) r.Post("/api/login/discoverable/begin", s.Auth.LoginDiscoverableBegin) r.Post("/api/login/discoverable/finish", s.Auth.LoginDiscoverableFinish) diff --git a/internal/core/endpoints_auth_test.go b/internal/core/endpoints_auth_test.go index 3e21fcf..ba3208c 100644 --- a/internal/core/endpoints_auth_test.go +++ b/internal/core/endpoints_auth_test.go @@ -61,6 +61,39 @@ func TestPasswordSignupAndLogin(t *testing.T) { } } +// TestWebAuthnBeginRateLimited: the user-initiated passkey "begin" ceremonies +// share the credential-attempt limiter. register/begin persists an account row, +// so an unthrottled loop would flood the users table and squat usernames; this +// is a regression guard for the pre-release audit finding that they were +// unlimited. A malformed body 400s in the handler, but the limiter middleware +// runs first — which is all this exercises — so after the burst we get 429. +func TestWebAuthnBeginRateLimited(t *testing.T) { + t.Parallel() + for _, path := range []string{"/api/register/begin", "/api/login/begin"} { + t.Run(path, func(t *testing.T) { + t.Parallel() + _, _, ts, h := splitServer(t) + + var got429, gotAllowed bool + for i := 0; i < 15; i++ { + resp := post(t, ts, h.auth, path, url.Values{}) + resp.Body.Close() + if resp.StatusCode == http.StatusTooManyRequests { + got429 = true + } else { + gotAllowed = true + } + } + if !gotAllowed { + t.Fatalf("%s: every request throttled; burst should allow some through", path) + } + if !got429 { + t.Fatalf("%s: never returned 429; the rate limiter is not wired", path) + } + }) + } +} + // assertAccountOK asserts a 303 back to /account carrying a success ("ok") flash. func assertAccountOK(t *testing.T, resp *http.Response, label string) { t.Helper()