Verify login credentials before spending rate-limit budget (closes #150)
All checks were successful
check / check (push) Successful in 2m47s
All checks were successful
check / check (push) Successful in 2m47s
With TRUSTED_PROXIES empty behind the reverse proxy production is required to run behind, every login POST keyed on the proxy's address and shared one 5/minute bucket. A stranger sending five POSTs a minute -- 0.08 requests per second, from anywhere -- kept that bucket permanently full, and the operator's own correct password was answered 429 indefinitely with no second administrative path. The login POST no longer has a pre-emptive limiter. The handler verifies credentials first and spends budget only on a FAILED attempt, so a correct password is never throttled whatever the counters hold. Three things follow, and are implemented together because the first is unsafe without the other two: - Failures are counted per (client bucket, submitted username), five per minute, after which further failures get 429 with a Retry-After. A successful login clears the counter, so mistyping and then succeeding does not leave the operator throttled. - Both key sets are capped at 1024 entries. The submitted username is attacker-controlled, so past the first cap failures fall back to a counter keyed on the client alone, and past both caps a failure is answered as throttled without being recorded. Tracked state stays under half a megabyte and does not grow with invented usernames. - Concurrent Argon2id verifications are capped at two, a 128 MB ceiling at 64 MB per hash, and the queue for those slots is capped at 16 waiters. Every password-hashing endpoint takes a slot, including the password-change endpoint, which holds one across both its hashes. A request that waits five seconds without a slot is answered 503, and one that arrives with the queue already full is shed with 503 immediately rather than joining it. Bounding the wait alone would not bound memory, and the queue depth is sized from what a parked waiter measurably retains rather than from the 1 MB body cap, which bounds only the raw body read. The body-cap, CSRF and form-parsing middleware all run before the guard, so a waiter holds its parsed form plus its request header block for the whole wait. Measured on the pinned go1.26.1 toolchain as the HeapAlloc delta across two GCs with 64 waiters parked in the handler: an ordinary two-field login form retains ~0 MB, a 1 MB urlencoded body at Go's 10,000-parameter parse cap retains 2.82 MB (3.09 MB with %41 escapes), and the ~0.9 MB of headers the 1 MB header cap allows takes it to 4.18 MB. The retained parse and the header block dominate, not the raw body. So 16 waiters: 16 x 4.18 MB is about 67 MB of committed queue memory, and two slots drain a full 16-deep queue in about 0.6 s, far inside the deadline. Peak commitment for the endpoint is about 203 MB — 128 MB of Argon2id plus the 18 requests holding a parsed form, 16 queued and the 2 being hashed, at about 75 MB. An unknown username is verified against a dummy hash instead of returning early, so a nonexistent account costs the same time as a real one and the response cannot be used to enumerate usernames. The password-change limiter is unchanged: RequireAuth runs ahead of it, so only a request already carrying a valid session reaches its bucket. Two consequences are documented rather than fixed, because they follow from the shape the issue asks for. Online guessing throughput rises from 5 a minute to roughly 27 a second, about 2.3 million a day: the credential check always precedes the counter, so the 429 is a label on the response rather than a gate in front of the hash, and what bounds brute force is the semaphore. And under a sustained flood the residual exposure is a loss of login availability, not merely of latency -- above about 27 requests a second most attempts are shed with 503, so a determined flood still denies login for as long as it runs. It costs roughly 400x more to run, nothing accumulates, and the first attempt after it stops succeeds. Restarting the service does not help: the counters a restart clears are not what is saturated. Also adds the missing test for the third bucketKey call site, where the peer is a trusted proxy but the forwarded chain names no client. Every existing test of that fallback uses an IPv4 proxy, where bucketKey is the identity function, so dropping the /64 masking there left the suite green. README and the TRUSTED_PROXIES startup warning updated: a shared bucket now costs precision, not the availability of the admin path.
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