package console // In-memory rate limits for email sign-in (spec §5, §7). Same shape as // inviteWindow: a mutex, an injectable clock, and a map of timestamps pruned // when a key is touched. State is per process and lost on restart: a restart // resets every window, the hourly and daily send and wrong-code budgets // included, so an abuser gets a fresh budget after each restart. That is an // accepted trade for abuse limits that guard no secret. // // Callers pass an already-normalised address (trimmed, lower-cased) so that // " Bob@Example.COM " and "bob@example.com" share one budget. import ( "sync" "time" ) const ( submitsPerIPHour = 30 // /auth/continue submissions per IP signinPerAddrHour = 5 // sign-in emails per address signinPerAddrDay = 20 // sign-in emails per address signinPerIPHour = 10 // sign-in emails per IP signinGlobalHour = 200 // sign-in emails console-wide stepUpPerAddrHour = 10 // step-up emails per address wrongCodesPerAddrDay = 10 // wrong codes per address wrongCodesPerIPHour = 30 // wrong codes per IP emailResendGap = 60 * time.Second // A key untouched for this long holds nothing that matters to any window. limitStaleAfter = 24 * time.Hour // submit inspects at most this many keys per call, across all windows. limitSweepBudget = 64 ) // keyedWindow holds the recent event times for each key. The zero value is // ready to use; the owning emailLimits holds the lock. type keyedWindow struct { hits map[string][]time.Time } // trim drops key's times older than limitStaleAfter, the longest any window // looks back, and the key itself when nothing is left. Shorter windows must // not prune on their own cutoff: the same key feeds the longer ones. func (w *keyedWindow) trim(key string, now time.Time) { ts := w.hits[key] if len(ts) == 0 { return } cutoff := now.Add(-limitStaleAfter) kept := ts[:0] for _, t := range ts { if t.After(cutoff) { kept = append(kept, t) } } if len(kept) == 0 { delete(w.hits, key) return } w.hits[key] = kept } // count is the number of times for key after cutoff. func (w *keyedWindow) count(key string, cutoff time.Time) int { n := 0 for _, t := range w.hits[key] { if t.After(cutoff) { n++ } } return n } // last is the newest time for key after cutoff, or false. func (w *keyedWindow) last(key string, cutoff time.Time) (time.Time, bool) { ts := w.hits[key] if len(ts) == 0 || !ts[len(ts)-1].After(cutoff) { return time.Time{}, false } return ts[len(ts)-1], true } // remove drops one time equal to t from key, the newest such, if any. func (w *keyedWindow) remove(key string, t time.Time) { ts := w.hits[key] for i := len(ts) - 1; i >= 0; i-- { if ts[i].Equal(t) { ts = append(ts[:i], ts[i+1:]...) break } } if len(ts) == 0 { delete(w.hits, key) return } w.hits[key] = ts } func (w *keyedWindow) add(key string, now time.Time) { if w.hits == nil { w.hits = map[string][]time.Time{} } w.hits[key] = append(w.hits[key], now) } // sweep inspects at most budget keys, drops those whose newest time is older // than cutoff, and returns how many it inspected. Go starts each map range at // a random position, so repeated calls cover the whole map over time. func (w *keyedWindow) sweep(cutoff time.Time, budget int) int { inspected := 0 for k, ts := range w.hits { if inspected >= budget { break } inspected++ if len(ts) == 0 || !ts[len(ts)-1].After(cutoff) { delete(w.hits, k) } } return inspected } // emailLimits is the set of windows email sign-in needs. The zero value is // ready to use. type emailLimits struct { mu sync.Mutex now func() time.Time // nil: time.Now submits keyedWindow // /auth/continue submissions, by IP addrSends keyedWindow // sign-in emails, by address ipSends keyedWindow // sign-in emails, by IP globalSend []time.Time // sign-in emails, console-wide stepUps keyedWindow // step-up emails, by address addrWrong keyedWindow // wrong codes, by address ipWrong keyedWindow // wrong codes, by IP sweepNext int // index into sweepWindows() where the next sweep starts } func (l *emailLimits) clock() time.Time { if l.now != nil { return l.now() } return time.Now() } // submit records one /auth/continue submission from ip and reports true, or // reports false without recording when ip already has 30 in the last hour. It // also runs one bounded cleanup step (see sweep), so memory follows live // traffic. func (l *emailLimits) submit(ip string) bool { l.mu.Lock() defer l.mu.Unlock() now := l.clock() l.sweep(now) l.submits.trim(ip, now) if l.submits.count(ip, now.Add(-time.Hour)) >= submitsPerIPHour { return false } l.submits.add(ip, now) return true } func (l *emailLimits) sweepWindows() []*keyedWindow { return []*keyedWindow{&l.submits, &l.addrSends, &l.ipSends, &l.stepUps, &l.addrWrong, &l.ipWrong} } // sweep is one bounded step of cleanup: it inspects at most limitSweepBudget // keys in total across all windows (not per window), dropping stale ones, and // returns the number inspected. It starts at the window after where the last // call ran out of budget, so a huge live window cannot starve the others. The // caller holds l.mu. func (l *emailLimits) sweep(now time.Time) int { cutoff := now.Add(-limitStaleAfter) ws := l.sweepWindows() budget := limitSweepBudget for n := 0; n < len(ws) && budget > 0; n++ { i := (l.sweepNext + n) % len(ws) budget -= ws[i].sweep(cutoff, budget) if budget <= 0 { l.sweepNext = (i + 1) % len(ws) } } return limitSweepBudget - budget } // signinSend reports whether a sign-in email may go to addr from ip, and if so // records it in every window. All or nothing: a refusal by any one limit // charges none of them, so a refused call does not push any window forward. // Callers pass addr canonical (trimmed, lower-cased). func (l *emailLimits) signinSend(addr, ip string) bool { l.mu.Lock() defer l.mu.Unlock() now := l.clock() hour, day := now.Add(-time.Hour), now.Add(-24*time.Hour) l.addrSends.trim(addr, now) l.ipSends.trim(ip, now) if _, ok := l.addrSends.last(addr, now.Add(-emailResendGap)); ok { return false } if l.addrSends.count(addr, hour) >= signinPerAddrHour { return false } if l.addrSends.count(addr, day) >= signinPerAddrDay { return false } if l.ipSends.count(ip, hour) >= signinPerIPHour { return false } kept := l.globalSend[:0] for _, t := range l.globalSend { if t.After(hour) { kept = append(kept, t) } } l.globalSend = kept if len(kept) >= signinGlobalHour { return false } l.addrSends.add(addr, now) l.ipSends.add(ip, now) l.globalSend = append(l.globalSend, now) return true } // stepUpSend reports whether a step-up email may go to addr (one per 60 // seconds, ten per hour) and records it if so. Its budget is separate from // signinSend's. // Callers pass addr canonical (trimmed, lower-cased). func (l *emailLimits) stepUpSend(addr string) bool { l.mu.Lock() defer l.mu.Unlock() now := l.clock() l.stepUps.trim(addr, now) if _, ok := l.stepUps.last(addr, now.Add(-emailResendGap)); ok { return false } if l.stepUps.count(addr, now.Add(-time.Hour)) >= stepUpPerAddrHour { return false } l.stepUps.add(addr, now) return true } // reserveGuess is the guessing cap as one step, so concurrent guesses cannot // all pass on the same count. Under the lock it refuses (allowed false) once // addr has 10 guesses in 24 hours or ip has 30 in an hour; otherwise it // records one guess against both at once and returns done, through which the // caller reports the verdict: done(true), a right code, takes the reservation // back; done(false), any other outcome, keeps it as a wrong code. done acts // once; later calls do nothing. Refused, done is a no-op. // Callers pass addr canonical (trimmed, lower-cased). func (l *emailLimits) reserveGuess(addr, ip string) (done func(ok bool), allowed bool) { l.mu.Lock() defer l.mu.Unlock() now := l.clock() l.addrWrong.trim(addr, now) l.ipWrong.trim(ip, now) if l.addrWrong.count(addr, now.Add(-24*time.Hour)) >= wrongCodesPerAddrDay || l.ipWrong.count(ip, now.Add(-time.Hour)) >= wrongCodesPerIPHour { return func(bool) {}, false } l.addrWrong.add(addr, now) l.ipWrong.add(ip, now) var once sync.Once return func(ok bool) { once.Do(func() { if !ok { return } l.mu.Lock() defer l.mu.Unlock() l.addrWrong.remove(addr, now) l.ipWrong.remove(ip, now) }) }, true }