All checks were successful
check / check (push) Successful in 2m54s
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. 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 no hash runs for it. 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. 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.
116 lines
3.0 KiB
Go
116 lines
3.0 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// NewLoggingResponseWriterForTest wraps newLoggingResponseWriter
|
|
// for use in external test packages.
|
|
func NewLoggingResponseWriterForTest(
|
|
w http.ResponseWriter,
|
|
) *loggingResponseWriter {
|
|
return newLoggingResponseWriter(w)
|
|
}
|
|
|
|
// LoggingResponseWriterStatusCode returns the status code
|
|
// captured by the loggingResponseWriter.
|
|
func LoggingResponseWriterStatusCode(
|
|
lrw *loggingResponseWriter,
|
|
) int {
|
|
return lrw.statusCode
|
|
}
|
|
|
|
// IPFromHostPort exposes ipFromHostPort for testing.
|
|
func IPFromHostPort(hp string) string {
|
|
return ipFromHostPort(hp)
|
|
}
|
|
|
|
// ClientKeyForTest exposes clientKey for testing.
|
|
func ClientKeyForTest(m *Middleware, r *http.Request) string {
|
|
return m.clientKey(r)
|
|
}
|
|
|
|
// IsClientTLS exposes isClientTLS for testing.
|
|
func IsClientTLS(r *http.Request) bool {
|
|
return isClientTLS(r)
|
|
}
|
|
|
|
// LoginRateLimitConst exposes the loginRateLimit constant: the
|
|
// number of FAILED login attempts one client may make against one
|
|
// submitted username per interval.
|
|
const LoginRateLimitConst = loginRateLimit
|
|
|
|
// LoginFailureMaxKeysConst exposes the cap on each of the login
|
|
// guard's key sets.
|
|
const LoginFailureMaxKeysConst = loginFailureMaxKeys
|
|
|
|
// PasswordVerifyConcurrencyConst exposes the bound on concurrent
|
|
// Argon2id verifications.
|
|
const PasswordVerifyConcurrencyConst = passwordVerifyConcurrency
|
|
|
|
// LoginGuard is the login failure counter and verification
|
|
// semaphore, exposed for direct testing.
|
|
type LoginGuard = loginGuard
|
|
|
|
// NewLoginGuardForTest builds a guard with test-sized parameters.
|
|
func NewLoginGuardForTest(
|
|
limit int,
|
|
interval time.Duration,
|
|
maxKeys, concurrency int,
|
|
wait time.Duration,
|
|
) *LoginGuard {
|
|
return newLoginGuard(
|
|
limit, interval, maxKeys, concurrency, wait,
|
|
)
|
|
}
|
|
|
|
// SetNowForTest replaces the guard's clock.
|
|
func (g *LoginGuard) SetNowForTest(now func() time.Time) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
|
|
g.now = now
|
|
}
|
|
|
|
// FailForTest exposes fail.
|
|
func (g *LoginGuard) FailForTest(clientKey, username string) bool {
|
|
return g.fail(clientKey, username)
|
|
}
|
|
|
|
// SucceedForTest exposes succeed.
|
|
func (g *LoginGuard) SucceedForTest(clientKey, username string) {
|
|
g.succeed(clientKey, username)
|
|
}
|
|
|
|
// AcquireForTest exposes acquire.
|
|
func (g *LoginGuard) AcquireForTest(
|
|
ctx context.Context,
|
|
) (func(), bool) {
|
|
return g.acquire(ctx)
|
|
}
|
|
|
|
// TrackedKeysForTest reports how many failure counters the guard
|
|
// holds, per-username and per-address respectively.
|
|
func (g *LoginGuard) TrackedKeysForTest() (int, int) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
|
|
return len(g.byUser), len(g.byAddr)
|
|
}
|
|
|
|
// PasswordChangeRateLimitConst exposes the
|
|
// passwordChangeRateLimit constant.
|
|
const PasswordChangeRateLimitConst = passwordChangeRateLimit
|
|
|
|
// ReceiverAggregateMultiplierConst exposes the
|
|
// receiverAggregateMultiplier constant.
|
|
const ReceiverAggregateMultiplierConst = receiverAggregateMultiplier
|
|
|
|
// ReceiverAggregateLimitForTest exposes receiverAggregateLimit for
|
|
// testing.
|
|
func ReceiverAggregateLimitForTest(perEntrypoint int) int {
|
|
return receiverAggregateLimit(perEntrypoint)
|
|
}
|