Rate limit webauthn endpoints

This commit is contained in:
Jonathon Leight committed 2026-07-05 12:02:01 -04:00
1 parent 1f68a4b047
commit 3af089aada
2 files changed
+41 -2

No files matched your search

+8 -2
View File
@@ -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)
+33
View File
@@ -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()