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:
@@ -12,11 +12,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// loginRateLimit is the maximum number of login attempts
|
||||
// per interval.
|
||||
// loginRateLimit is the maximum number of FAILED login attempts
|
||||
// one client may make against one submitted username per
|
||||
// interval before further failures are answered 429. Successful
|
||||
// attempts are never counted and never throttled — see
|
||||
// loginGuard.
|
||||
loginRateLimit = 5
|
||||
|
||||
// loginRateInterval is the time window for the rate limit.
|
||||
// loginRateInterval is the time window for the login failure
|
||||
// limit.
|
||||
loginRateInterval = 1 * time.Minute
|
||||
|
||||
// passwordChangeRateLimit is the maximum number of password
|
||||
@@ -216,7 +220,7 @@ func (m *Middleware) clientKey(r *http.Request) string {
|
||||
return bucketKey(peer)
|
||||
}
|
||||
|
||||
// tooManyRequests returns the 429 handler used by the login,
|
||||
// tooManyRequests returns the 429 handler used by the
|
||||
// password-change and per-entrypoint receiver limiters: it logs the
|
||||
// rejection with logMessage and answers with responseMessage.
|
||||
// httprate adds the Retry-After header (RFC 6585). The aggregate
|
||||
@@ -255,26 +259,15 @@ func (m *Middleware) floodTooManyRequests(
|
||||
}
|
||||
}
|
||||
|
||||
// LoginRateLimit returns middleware that enforces per-IP rate
|
||||
// limiting on login attempts using go-chi/httprate. Only POST
|
||||
// requests are rate-limited; GET requests (rendering the login
|
||||
// form) pass through unaffected. When the rate limit is exceeded,
|
||||
// a 429 Too Many Requests response is returned. Clients are
|
||||
// identified by rateLimitKey.
|
||||
func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
|
||||
return m.postRateLimit(
|
||||
loginRateLimit,
|
||||
loginRateInterval,
|
||||
"login rate limit exceeded",
|
||||
"Too many login attempts. Please try again later.",
|
||||
)
|
||||
}
|
||||
|
||||
// PasswordChangeRateLimit returns middleware that enforces
|
||||
// per-IP rate limiting on password change attempts. The change
|
||||
// endpoint verifies the current password, so without a limit a
|
||||
// stolen session could be used to brute-force it; the limit
|
||||
// matches the login endpoint's.
|
||||
// stolen session could be used to brute-force it.
|
||||
//
|
||||
// Unlike the login POST this limit is still spent on arrival, which
|
||||
// is safe here: RequireAuth runs ahead of it, so only a request
|
||||
// already carrying a valid session can reach the bucket, and an
|
||||
// operator locked out of changing a password can still log in.
|
||||
func (m *Middleware) PasswordChangeRateLimit() func(http.Handler) http.Handler {
|
||||
return m.postRateLimit(
|
||||
passwordChangeRateLimit,
|
||||
|
||||
Reference in New Issue
Block a user