Verify login credentials before spending rate-limit budget (closes #150)
All checks were successful
check / check (push) Successful in 2m46s
All checks were successful
check / check (push) Successful in 2m46s
In the shipped default, any stranger denied the operator the only administrative path at 5 requests per minute: TRUSTED_PROXIES is empty, the README requires a reverse proxy, so every login POST shared one bucket keyed on the proxy. Credentials are now verified first and only a FAILED attempt spends budget, so a correct password is never throttled. Failures are counted per (client bucket, submitted username), bounded. Concurrent Argon2id verifications are capped at two, and the queue for them at 16 — because verifying first lets an attacker force a 64 MB hash per request, and bounding the wait alone bounds nothing. The issue's own recommendation was insufficient and is rejected here: keying by username stops an attacker locking out a DIFFERENT account, but this is a single-admin product with a predictable bootstrap username, so flooding the operator's own name still locks them out. This is speculative — it implements a corrected recommendation ahead of the owner's ruling so the decision can be made by merging or reverting. Three things are disclosed rather than glossed: online guessing rises from 5/min to roughly 27/s, because the 429 is a label on the response and not a gate in front of the hash; the residual exposure is a loss of login AVAILABILITY, not latency, and a determined flood still denies login while it runs, at ~400x the cost and clearing the moment it stops; and the endpoint should be provisioned for ~400 MB resident, not the 203 MB of live commitment it itemises. Independently reviewed four times. Reviewers disproved the suspected FIFO starvation by measurement, then caught two successive memory bounds the code did not have — the second by parking waiters and reading the heap rather than checking the arithmetic.
This commit was merged in pull request #171.
This commit is contained in:
@@ -96,11 +96,14 @@ func (s *Server) setupPageRoutes() {
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.NoCache())
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.mw.LoginRateLimit())
|
||||
r.Get("/login", s.h.HandleLoginPage())
|
||||
r.Post("/login", s.h.HandleLoginSubmit())
|
||||
})
|
||||
// The login POST carries no pre-emptive rate limiter. Behind
|
||||
// the reverse proxy production requires, with TRUSTED_PROXIES
|
||||
// unset, every client shares one bucket, so a limiter spent
|
||||
// on arrival lets any stranger deny the operator the only
|
||||
// administrative path. The handler verifies credentials first
|
||||
// and charges only failures; see Handlers.authenticateUser.
|
||||
r.Get("/login", s.h.HandleLoginPage())
|
||||
r.Post("/login", s.h.HandleLoginSubmit())
|
||||
|
||||
r.Post("/logout", s.h.HandleLogout())
|
||||
})
|
||||
|
||||
@@ -420,6 +420,78 @@ func TestPagesLogin_UnderLimit_ValidToken_ReachesHandler(
|
||||
)
|
||||
}
|
||||
|
||||
// TestPagesLogin_CorrectPasswordSurvivesASpentBudget pins the
|
||||
// routing half of the fix, which every other login test misses by
|
||||
// driving the handler directly: no pre-emptive limiter sits in front
|
||||
// of POST /pages/login on the real route tree.
|
||||
//
|
||||
// A limiter registered there would answer the last request 429
|
||||
// however correct its password is, because the wrong passwords
|
||||
// before it have already spent the bucket — which is the lockout
|
||||
// this endpoint exists to not have. CSRF and the body cap still run,
|
||||
// since every request here carries a harvested token.
|
||||
func TestPagesLogin_CorrectPasswordSurvivesASpentBudget(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
username = "operator"
|
||||
password = "correct-horse-battery-staple"
|
||||
)
|
||||
|
||||
env := newTestEnv(t)
|
||||
env.seedUser(t, username, password)
|
||||
|
||||
submit := func(t *testing.T, pw string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
token, cookies := env.csrfFrom(t, "/pages/login", nil)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", token)
|
||||
form.Set("username", username)
|
||||
form.Set("password", pw)
|
||||
|
||||
return env.post("/pages/login", form, cookies)
|
||||
}
|
||||
|
||||
// Spend the failure budget against this username. The exact
|
||||
// limit belongs to the middleware; this waits for the throttle
|
||||
// to appear rather than restating it, under a ceiling well
|
||||
// above it so a broken limiter fails the test instead of
|
||||
// looping.
|
||||
const maxAttempts = 20
|
||||
|
||||
spent := false
|
||||
|
||||
for range maxAttempts {
|
||||
code := submit(t, "wrong").Code
|
||||
if code == http.StatusTooManyRequests {
|
||||
spent = true
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
require.Equal(
|
||||
t, http.StatusUnauthorized, code,
|
||||
"a wrong password must be rejected, not accepted",
|
||||
)
|
||||
}
|
||||
|
||||
require.True(
|
||||
t, spent,
|
||||
"repeated wrong passwords must eventually be throttled",
|
||||
)
|
||||
|
||||
assert.Equal(
|
||||
t, http.StatusSeeOther, submit(t, password).Code,
|
||||
"a correct password must be accepted on the routed "+
|
||||
"endpoint even with the failure budget spent: the "+
|
||||
"operator has no second administrative path",
|
||||
)
|
||||
}
|
||||
|
||||
// --- /user/{username} group ---
|
||||
|
||||
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
||||
|
||||
Reference in New Issue
Block a user