carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package 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
13import (
14 "sync"
15 "time"
16)
17
18const (
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.
38type 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.
45func (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.
65func (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.
76func (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.
85func (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
100func (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.
110func (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.
126type 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
141func (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.
152func (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
165func (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.
174func (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).
192func (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).
233func (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).
256func (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