Documentation
¶
Overview ¶
Package ratelimit provides a keyed strike counter with a hard lockout window, for guarding credential-checking endpoints against brute force.
An AttemptLimiter admits a bounded number of attempts per caller-supplied string key — an email, a client IP, any attacker-controlled value — and locks a key out for Policy.LockoutWindow once it has used its Policy.Threshold. It is a strike counter, not a token bucket: there is no refill rate, a key is either within its budget or locked out.
Atomic admission ¶
AttemptLimiter.Attempt checks the lockout and counts the attempt under one lock. Checking first and recording afterwards would let any number of concurrent requests pass the check before the first is counted. A caller that wants a success to clear a key calls AttemptLimiter.Reset. A key whose success must not clear it simply keeps the attempt counted, so an attacker holding one valid credential cannot interleave successes to wipe the strikes of their guessing. An attempt that should never have counted (an internal error, a request rejected by a second limiter) is taken back with AttemptLimiter.Cancel and the Reservation from AttemptLimiter.Reserve; cancelling the very attempt that engaged a lockout lifts it, while cancelling any other never does.
Bounded memory ¶
The key space is attacker-supplied, so at most Policy.MaxTrackedKeys keys are held. A lockout is never evicted to make room: if every slot holds a lockout, new keys are denied until one expires. Bookkeeping is amortised O(1) per call, so a flood of distinct keys cannot make callers queue behind a scan of the whole table.
Process-lifetime state ¶
Strike state lives in memory and dies with the process. Two binaries that share one credential store each keep their own counters, so a shared credential gets at most Threshold attempts per window per application, and a restart clears every lockout. That is no worse than an attacker waiting out the window; persisting strikes in the shared identity schema is explicitly deferred.
Where it lives ¶
The package is generic on purpose. It knows nothing about HTTP or accounts, so the per-IP dimension (HTTP-shaped) and the per-account dimension (identity-shaped) are both expressed by the caller choosing keys, and the caller supplies the clock on every call.
Index ¶
- type AttemptLimiter
- func (l *AttemptLimiter) Attempt(key string, now time.Time) Outcome
- func (l *AttemptLimiter) Cancel(r Reservation)
- func (l *AttemptLimiter) Locked(key string, now time.Time) bool
- func (l *AttemptLimiter) Refund(key string)deprecated
- func (l *AttemptLimiter) Reserve(key string, now time.Time) (Outcome, Reservation)
- func (l *AttemptLimiter) Reset(key string)
- type Outcome
- type Policy
- type Reservation
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type AttemptLimiter ¶
type AttemptLimiter struct {
// contains filtered or unexported fields
}
AttemptLimiter admits a bounded number of attempts per key per window and locks a key out once it has used its budget. It is safe for concurrent use. Construct one with NewAttemptLimiter; the zero value is not usable.
AttemptLimiter.Attempt checks the lockout and counts the attempt under one lock, so concurrent requests cannot all slip past a check made before any of them is counted. A caller that wants a success to clear a key calls AttemptLimiter.Reset; a key a success must not clear keeps the attempt counted. An attempt that should never have counted (an internal error, a request rejected by a second limiter) is taken back with AttemptLimiter.Cancel using the Reservation from AttemptLimiter.Reserve.
Entries are kept in two lists, each ordered by recency of activity: keys still collecting attempts, and keys that are locked. A lockout ends exactly one window after the attempt that engaged it, so each list expires from its back and every operation is amortised O(1) however many keys are tracked. Callers must pass non-decreasing times.
Threat model ¶
Keys are attacker-supplied, so the table is capped, and a full table forces a trade between two attacks: evicting lockouts would let a flood of distinct keys erase a victim's lockout, while refusing new keys would let the same flood deny every legitimate user. The settled design is: lockouts are never evicted; a slot is made by dropping the least recently active counting key (one that has not yet used its budget); and only when every slot holds a live lockout are new keys denied.
The residual risk is that a flood can erase another source's in-progress strikes, or, if it fills every slot with lockouts, deny new keys until one expires. Both need on the order of MaxTrackedKeys distinct keys within one window. That is only affordable when the flood is not itself throttled, so callers must pair a per-key limiter (an account) with a per-client one (an IP) and consult the client limiter FIRST: one source then locks out after its own Threshold and cannot mint distinct account keys faster than that. Filling the table then takes roughly MaxTrackedKeys distinct client addresses. Size MaxTrackedKeys generously: an entry costs about 250 bytes (key string, entry, list element and map slot), so 50,000 keys is about 12 MB.
func NewAttemptLimiter ¶
func NewAttemptLimiter(p Policy) *AttemptLimiter
NewAttemptLimiter constructs an empty limiter enforcing p.
It panics if p.Threshold < 1, p.LockoutWindow <= 0 or p.MaxTrackedKeys < 1: a limiter that silently never limits is worse than one that refuses to start.
func (*AttemptLimiter) Attempt ¶
func (l *AttemptLimiter) Attempt(key string, now time.Time) Outcome
Attempt atomically checks key's lockout and, unless denied, counts one attempt against its budget.
A denied attempt is not counted, so retrying a locked key does not extend its lockout. The Threshold-th admitted attempt returns AllowedLastAttempt and locks the key for Policy.LockoutWindow. Use AttemptLimiter.Reserve instead when the attempt may have to be cancelled.
func (*AttemptLimiter) Cancel ¶ added in v0.5.0
func (l *AttemptLimiter) Cancel(r Reservation)
Cancel takes back the attempt behind r, for an attempt that should never have counted (an internal error, a request rejected by a second limiter). Call it at most once per reservation.
It is exact about which attempt it cancels. Every cancelled attempt is given back, so concurrent cancels never leave strikes behind. But a lockout is lifted only when r is the attempt that engaged it; if a different attempt engaged it, it stands, so an unrelated in-flight attempt can never undo an attacker's strikes. A reservation that outlived its entry (reset, expired or evicted since) cancels nothing.
func (*AttemptLimiter) Locked ¶
func (l *AttemptLimiter) Locked(key string, now time.Time) bool
Locked reports, without counting anything, whether an attempt for key would be denied as of now. It is a cheap pre-check; AttemptLimiter.Attempt is the authoritative gate. A key the limiter has no room to track is reported locked, matching what Attempt would do.
func (*AttemptLimiter) Refund
deprecated
func (l *AttemptLimiter) Refund(key string)
Refund gives back one attempt previously admitted for key, for an attempt that should not have counted (an internal error, a request rejected by a second limiter). It only ever lowers the count of a key that is still counting, forgetting it at zero. A locked key is left alone: an anonymous refund cannot tell whether it belongs to the attempt that engaged the lockout or to an unrelated earlier one, and lifting a lockout on the strength of the latter would let any in-flight success undo an attacker's strikes. The lockout stands until it expires or AttemptLimiter.Reset.
Deprecated: Refund cannot say which attempt it returns, so it cannot undo an attempt that engaged a lockout. Use AttemptLimiter.Reserve and AttemptLimiter.Cancel.
func (*AttemptLimiter) Reserve ¶ added in v0.5.0
func (l *AttemptLimiter) Reserve(key string, now time.Time) (Outcome, Reservation)
Reserve is AttemptLimiter.Attempt that also returns a Reservation for the attempt it admitted.
func (*AttemptLimiter) Reset ¶
func (l *AttemptLimiter) Reset(key string)
Reset forgets key entirely, lockout included, e.g. after a correct password.
type Outcome ¶
type Outcome int
Outcome is the verdict of AttemptLimiter.Attempt. The zero value is Denied, so an unset Outcome fails closed.
const ( // Denied means the attempt must not proceed: the key is locked out, or // the limiter is saturated with lockouts and cannot track a new key. Denied Outcome = iota // Allowed means the attempt may proceed. Allowed // AllowedLastAttempt means the attempt may proceed but it used up the // key's budget and engaged the lockout. It is returned exactly once per // lockout, so callers can log one line per lockout. AllowedLastAttempt )
type Policy ¶
type Policy struct {
// Threshold is the number of attempts admitted per key per window. The
// Threshold-th admitted attempt engages the lockout, so a Threshold of 5
// admits five attempts and denies the sixth.
Threshold int
// LockoutWindow is both how long a key stays locked once it uses up its
// Threshold and how long attempts are remembered: a key idle for a full
// window starts counting from zero again, so the budget means "N
// attempts within a window", not "N attempts ever".
LockoutWindow time.Duration
// MaxTrackedKeys bounds the number of distinct keys held in memory. Keys
// are attacker-supplied, so an unbounded map is a memory-exhaustion
// vector. At the cap, a new key first displaces the least recently active
// key that is NOT locked. A lockout is never evicted: when every tracked
// key is locked, new keys are denied (fail closed) until one expires.
MaxTrackedKeys int
}
Policy tunes an AttemptLimiter. Every field must be positive; NewAttemptLimiter panics otherwise.
type Reservation ¶ added in v0.5.0
type Reservation struct {
// contains filtered or unexported fields
}
Reservation identifies one admitted attempt so it can be taken back with AttemptLimiter.Cancel. The zero value (what a denied attempt returns) cancels nothing.