2207ac6132
Add an in-memory SendLimiter enforcing a one-per-minute cooldown and a five-per-rolling-hour cap per recipient address, checked before provisioning or sending in RequestCode, RequestLoginCode and RequestLinkCode. It guards against email bombing and protects the relay quota. The limiter is injected in main (nil in tests, so the domain suite is unaffected); ErrTooManyRequests maps to HTTP 429.
67 lines
1.9 KiB
Go
67 lines
1.9 KiB
Go
package account
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// SendLimiter throttles confirm-code sends per recipient address: it enforces a
|
|
// minimum cooldown between two sends and a cap over a rolling hour. It guards against
|
|
// email bombing and protects the relay's own quota. State is in-memory (per process,
|
|
// reset on restart) and keyed by the normalised recipient address, which is adequate
|
|
// for the single-instance backend. Safe for concurrent use.
|
|
type SendLimiter struct {
|
|
mu sync.Mutex
|
|
cooldown time.Duration
|
|
perHour int
|
|
now func() time.Time
|
|
sends map[string][]time.Time
|
|
}
|
|
|
|
// NewSendLimiter returns a SendLimiter allowing at most one send per cooldown and at
|
|
// most perHour sends over any rolling hour, to the same recipient.
|
|
func NewSendLimiter(cooldown time.Duration, perHour int) *SendLimiter {
|
|
return &SendLimiter{
|
|
cooldown: cooldown,
|
|
perHour: perHour,
|
|
now: func() time.Time { return time.Now() },
|
|
sends: make(map[string][]time.Time),
|
|
}
|
|
}
|
|
|
|
// Allow reports whether a send to key is permitted now, recording the send when it is.
|
|
// It is denied when the last send was within the cooldown or the rolling-hour cap is
|
|
// already reached.
|
|
func (l *SendLimiter) Allow(key string) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
now := l.now()
|
|
cutoff := now.Add(-time.Hour)
|
|
kept := l.sends[key][:0]
|
|
for _, t := range l.sends[key] {
|
|
if t.After(cutoff) {
|
|
kept = append(kept, t)
|
|
}
|
|
}
|
|
if n := len(kept); n > 0 && now.Sub(kept[n-1]) < l.cooldown {
|
|
l.set(key, kept)
|
|
return false
|
|
}
|
|
if len(kept) >= l.perHour {
|
|
l.set(key, kept)
|
|
return false
|
|
}
|
|
l.set(key, append(kept, now))
|
|
return true
|
|
}
|
|
|
|
// set stores the retained send times for key, dropping the entry entirely once empty
|
|
// so the map stays bounded to recipients active within the last hour.
|
|
func (l *SendLimiter) set(key string, times []time.Time) {
|
|
if len(times) == 0 {
|
|
delete(l.sends, key)
|
|
return
|
|
}
|
|
l.sends[key] = times
|
|
}
|