| 1 | package console |
| 2 | |
| 3 | // In-memory rate limits for email sign-in (spec §5, §7). Same shape as |
| 4 | // inviteWindow: a mutex, an injectable clock, and a map of timestamps pruned |
| 5 | // when a key is touched. State is per process and lost on restart: a restart |
| 6 | // resets every window, the hourly and daily send and wrong-code budgets |
| 7 | // included, so an abuser gets a fresh budget after each restart. That is an |
| 8 | // accepted trade for abuse limits that guard no secret. |
| 9 | // |
| 10 | // Callers pass an already-normalised address (trimmed, lower-cased) so that |
| 11 | // " Bob@Example.COM " and "bob@example.com" share one budget. |
| 12 | |
| 13 | import ( |
| 14 | "sync" |
| 15 | "time" |
| 16 | ) |
| 17 | |
| 18 | const ( |
| 19 | submitsPerIPHour = 30 // /auth/continue submissions per IP |
| 20 | signinPerAddrHour = 5 // sign-in emails per address |
| 21 | signinPerAddrDay = 20 // sign-in emails per address |
| 22 | signinPerIPHour = 10 // sign-in emails per IP |
| 23 | signinGlobalHour = 200 // sign-in emails console-wide |
| 24 | stepUpPerAddrHour = 10 // step-up emails per address |
| 25 | wrongCodesPerAddrDay = 10 // wrong codes per address |
| 26 | wrongCodesPerIPHour = 30 // wrong codes per IP |
| 27 | |
| 28 | emailResendGap = 60 * time.Second |
| 29 | |
| 30 | // A key untouched for this long holds nothing that matters to any window. |
| 31 | limitStaleAfter = 24 * time.Hour |
| 32 | // submit inspects at most this many keys per call, across all windows. |
| 33 | limitSweepBudget = 64 |
| 34 | ) |
| 35 | |
| 36 | // keyedWindow holds the recent event times for each key. The zero value is |
| 37 | // ready to use; the owning emailLimits holds the lock. |
| 38 | type keyedWindow struct { |
| 39 | hits map[string][]time.Time |
| 40 | } |
| 41 | |
| 42 | // trim drops key's times older than limitStaleAfter, the longest any window |
| 43 | // looks back, and the key itself when nothing is left. Shorter windows must |
| 44 | // not prune on their own cutoff: the same key feeds the longer ones. |
| 45 | func (w *keyedWindow) trim(key string, now time.Time) { |
| 46 | ts := w.hits[key] |
| 47 | if len(ts) == 0 { |
| 48 | return |
| 49 | } |
| 50 | cutoff := now.Add(-limitStaleAfter) |
| 51 | kept := ts[:0] |
| 52 | for _, t := range ts { |
| 53 | if t.After(cutoff) { |
| 54 | kept = append(kept, t) |
| 55 | } |
| 56 | } |
| 57 | if len(kept) == 0 { |
| 58 | delete(w.hits, key) |
| 59 | return |
| 60 | } |
| 61 | w.hits[key] = kept |
| 62 | } |
| 63 | |
| 64 | // count is the number of times for key after cutoff. |
| 65 | func (w *keyedWindow) count(key string, cutoff time.Time) int { |
| 66 | n := 0 |
| 67 | for _, t := range w.hits[key] { |
| 68 | if t.After(cutoff) { |
| 69 | n++ |
| 70 | } |
| 71 | } |
| 72 | return n |
| 73 | } |
| 74 | |
| 75 | // last is the newest time for key after cutoff, or false. |
| 76 | func (w *keyedWindow) last(key string, cutoff time.Time) (time.Time, bool) { |
| 77 | ts := w.hits[key] |
| 78 | if len(ts) == 0 || !ts[len(ts)-1].After(cutoff) { |
| 79 | return time.Time{}, false |
| 80 | } |
| 81 | return ts[len(ts)-1], true |
| 82 | } |
| 83 | |
| 84 | // remove drops one time equal to t from key, the newest such, if any. |
| 85 | func (w *keyedWindow) remove(key string, t time.Time) { |
| 86 | ts := w.hits[key] |
| 87 | for i := len(ts) - 1; i >= 0; i-- { |
| 88 | if ts[i].Equal(t) { |
| 89 | ts = append(ts[:i], ts[i+1:]...) |
| 90 | break |
| 91 | } |
| 92 | } |
| 93 | if len(ts) == 0 { |
| 94 | delete(w.hits, key) |
| 95 | return |
| 96 | } |
| 97 | w.hits[key] = ts |
| 98 | } |
| 99 | |
| 100 | func (w *keyedWindow) add(key string, now time.Time) { |
| 101 | if w.hits == nil { |
| 102 | w.hits = map[string][]time.Time{} |
| 103 | } |
| 104 | w.hits[key] = append(w.hits[key], now) |
| 105 | } |
| 106 | |
| 107 | // sweep inspects at most budget keys, drops those whose newest time is older |
| 108 | // than cutoff, and returns how many it inspected. Go starts each map range at |
| 109 | // a random position, so repeated calls cover the whole map over time. |
| 110 | func (w *keyedWindow) sweep(cutoff time.Time, budget int) int { |
| 111 | inspected := 0 |
| 112 | for k, ts := range w.hits { |
| 113 | if inspected >= budget { |
| 114 | break |
| 115 | } |
| 116 | inspected++ |
| 117 | if len(ts) == 0 || !ts[len(ts)-1].After(cutoff) { |
| 118 | delete(w.hits, k) |
| 119 | } |
| 120 | } |
| 121 | return inspected |
| 122 | } |
| 123 | |
| 124 | // emailLimits is the set of windows email sign-in needs. The zero value is |
| 125 | // ready to use. |
| 126 | type emailLimits struct { |
| 127 | mu sync.Mutex |
| 128 | now func() time.Time // nil: time.Now |
| 129 | |
| 130 | submits keyedWindow // /auth/continue submissions, by IP |
| 131 | addrSends keyedWindow // sign-in emails, by address |
| 132 | ipSends keyedWindow // sign-in emails, by IP |
| 133 | globalSend []time.Time // sign-in emails, console-wide |
| 134 | stepUps keyedWindow // step-up emails, by address |
| 135 | addrWrong keyedWindow // wrong codes, by address |
| 136 | ipWrong keyedWindow // wrong codes, by IP |
| 137 | |
| 138 | sweepNext int // index into sweepWindows() where the next sweep starts |
| 139 | } |
| 140 | |
| 141 | func (l *emailLimits) clock() time.Time { |
| 142 | if l.now != nil { |
| 143 | return l.now() |
| 144 | } |
| 145 | return time.Now() |
| 146 | } |
| 147 | |
| 148 | // submit records one /auth/continue submission from ip and reports true, or |
| 149 | // reports false without recording when ip already has 30 in the last hour. It |
| 150 | // also runs one bounded cleanup step (see sweep), so memory follows live |
| 151 | // traffic. |
| 152 | func (l *emailLimits) submit(ip string) bool { |
| 153 | l.mu.Lock() |
| 154 | defer l.mu.Unlock() |
| 155 | now := l.clock() |
| 156 | l.sweep(now) |
| 157 | l.submits.trim(ip, now) |
| 158 | if l.submits.count(ip, now.Add(-time.Hour)) >= submitsPerIPHour { |
| 159 | return false |
| 160 | } |
| 161 | l.submits.add(ip, now) |
| 162 | return true |
| 163 | } |
| 164 | |
| 165 | func (l *emailLimits) sweepWindows() []*keyedWindow { |
| 166 | return []*keyedWindow{&l.submits, &l.addrSends, &l.ipSends, &l.stepUps, &l.addrWrong, &l.ipWrong} |
| 167 | } |
| 168 | |
| 169 | // sweep is one bounded step of cleanup: it inspects at most limitSweepBudget |
| 170 | // keys in total across all windows (not per window), dropping stale ones, and |
| 171 | // returns the number inspected. It starts at the window after where the last |
| 172 | // call ran out of budget, so a huge live window cannot starve the others. The |
| 173 | // caller holds l.mu. |
| 174 | func (l *emailLimits) sweep(now time.Time) int { |
| 175 | cutoff := now.Add(-limitStaleAfter) |
| 176 | ws := l.sweepWindows() |
| 177 | budget := limitSweepBudget |
| 178 | for n := 0; n < len(ws) && budget > 0; n++ { |
| 179 | i := (l.sweepNext + n) % len(ws) |
| 180 | budget -= ws[i].sweep(cutoff, budget) |
| 181 | if budget <= 0 { |
| 182 | l.sweepNext = (i + 1) % len(ws) |
| 183 | } |
| 184 | } |
| 185 | return limitSweepBudget - budget |
| 186 | } |
| 187 | |
| 188 | // signinSend reports whether a sign-in email may go to addr from ip, and if so |
| 189 | // records it in every window. All or nothing: a refusal by any one limit |
| 190 | // charges none of them, so a refused call does not push any window forward. |
| 191 | // Callers pass addr canonical (trimmed, lower-cased). |
| 192 | func (l *emailLimits) signinSend(addr, ip string) bool { |
| 193 | l.mu.Lock() |
| 194 | defer l.mu.Unlock() |
| 195 | now := l.clock() |
| 196 | hour, day := now.Add(-time.Hour), now.Add(-24*time.Hour) |
| 197 | l.addrSends.trim(addr, now) |
| 198 | l.ipSends.trim(ip, now) |
| 199 | |
| 200 | if _, ok := l.addrSends.last(addr, now.Add(-emailResendGap)); ok { |
| 201 | return false |
| 202 | } |
| 203 | if l.addrSends.count(addr, hour) >= signinPerAddrHour { |
| 204 | return false |
| 205 | } |
| 206 | if l.addrSends.count(addr, day) >= signinPerAddrDay { |
| 207 | return false |
| 208 | } |
| 209 | if l.ipSends.count(ip, hour) >= signinPerIPHour { |
| 210 | return false |
| 211 | } |
| 212 | kept := l.globalSend[:0] |
| 213 | for _, t := range l.globalSend { |
| 214 | if t.After(hour) { |
| 215 | kept = append(kept, t) |
| 216 | } |
| 217 | } |
| 218 | l.globalSend = kept |
| 219 | if len(kept) >= signinGlobalHour { |
| 220 | return false |
| 221 | } |
| 222 | |
| 223 | l.addrSends.add(addr, now) |
| 224 | l.ipSends.add(ip, now) |
| 225 | l.globalSend = append(l.globalSend, now) |
| 226 | return true |
| 227 | } |
| 228 | |
| 229 | // stepUpSend reports whether a step-up email may go to addr (one per 60 |
| 230 | // seconds, ten per hour) and records it if so. Its budget is separate from |
| 231 | // signinSend's. |
| 232 | // Callers pass addr canonical (trimmed, lower-cased). |
| 233 | func (l *emailLimits) stepUpSend(addr string) bool { |
| 234 | l.mu.Lock() |
| 235 | defer l.mu.Unlock() |
| 236 | now := l.clock() |
| 237 | l.stepUps.trim(addr, now) |
| 238 | if _, ok := l.stepUps.last(addr, now.Add(-emailResendGap)); ok { |
| 239 | return false |
| 240 | } |
| 241 | if l.stepUps.count(addr, now.Add(-time.Hour)) >= stepUpPerAddrHour { |
| 242 | return false |
| 243 | } |
| 244 | l.stepUps.add(addr, now) |
| 245 | return true |
| 246 | } |
| 247 | |
| 248 | // reserveGuess is the guessing cap as one step, so concurrent guesses cannot |
| 249 | // all pass on the same count. Under the lock it refuses (allowed false) once |
| 250 | // addr has 10 guesses in 24 hours or ip has 30 in an hour; otherwise it |
| 251 | // records one guess against both at once and returns done, through which the |
| 252 | // caller reports the verdict: done(true), a right code, takes the reservation |
| 253 | // back; done(false), any other outcome, keeps it as a wrong code. done acts |
| 254 | // once; later calls do nothing. Refused, done is a no-op. |
| 255 | // Callers pass addr canonical (trimmed, lower-cased). |
| 256 | func (l *emailLimits) reserveGuess(addr, ip string) (done func(ok bool), allowed bool) { |
| 257 | l.mu.Lock() |
| 258 | defer l.mu.Unlock() |
| 259 | now := l.clock() |
| 260 | l.addrWrong.trim(addr, now) |
| 261 | l.ipWrong.trim(ip, now) |
| 262 | if l.addrWrong.count(addr, now.Add(-24*time.Hour)) >= wrongCodesPerAddrDay || |
| 263 | l.ipWrong.count(ip, now.Add(-time.Hour)) >= wrongCodesPerIPHour { |
| 264 | return func(bool) {}, false |
| 265 | } |
| 266 | l.addrWrong.add(addr, now) |
| 267 | l.ipWrong.add(ip, now) |
| 268 | var once sync.Once |
| 269 | return func(ok bool) { |
| 270 | once.Do(func() { |
| 271 | if !ok { |
| 272 | return |
| 273 | } |
| 274 | l.mu.Lock() |
| 275 | defer l.mu.Unlock() |
| 276 | l.addrWrong.remove(addr, now) |
| 277 | l.ipWrong.remove(ip, now) |
| 278 | }) |
| 279 | }, true |
| 280 | } |
| 281 | |