| 1 | package main |
| 2 | |
| 3 | // Content-blind spam brakes (issue #578): per-user token buckets on the few |
| 4 | // endpoints a flood abuses — messages, new DMs, invites. The server enforces |
| 5 | // them without reading anything (pure metadata), admins are exempt, and the |
| 6 | // ceilings are far above any human pace so a legitimate member never meets |
| 7 | // them. Ported from home/main.go's per-IP limiter, keyed by user id here. |
| 8 | |
| 9 | import ( |
| 10 | "sync" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | // modLimiter is a per-key token bucket. Buckets refill continuously and idle |
| 15 | // ones are swept when the map grows, so memory stays bounded. |
| 16 | type modLimiter struct { |
| 17 | mu sync.Mutex |
| 18 | buckets map[string]*modBucket |
| 19 | burst float64 |
| 20 | perSec float64 |
| 21 | } |
| 22 | |
| 23 | type modBucket struct { |
| 24 | tokens float64 |
| 25 | last time.Time |
| 26 | } |
| 27 | |
| 28 | func newModLimiter(burst, perSec float64) *modLimiter { |
| 29 | return &modLimiter{buckets: map[string]*modBucket{}, burst: burst, perSec: perSec} |
| 30 | } |
| 31 | |
| 32 | func (rl *modLimiter) allow(key string) bool { |
| 33 | rl.mu.Lock() |
| 34 | defer rl.mu.Unlock() |
| 35 | now := time.Now() |
| 36 | if len(rl.buckets) > 10000 { |
| 37 | for k, b := range rl.buckets { |
| 38 | if b.tokens >= rl.burst || now.Sub(b.last) > time.Hour { |
| 39 | delete(rl.buckets, k) |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | b, ok := rl.buckets[key] |
| 44 | if !ok { |
| 45 | b = &modBucket{tokens: rl.burst, last: now} |
| 46 | rl.buckets[key] = b |
| 47 | } |
| 48 | b.tokens += now.Sub(b.last).Seconds() * rl.perSec |
| 49 | if b.tokens > rl.burst { |
| 50 | b.tokens = rl.burst |
| 51 | } |
| 52 | b.last = now |
| 53 | if b.tokens < 1 { |
| 54 | return false |
| 55 | } |
| 56 | b.tokens-- |
| 57 | return true |
| 58 | } |
| 59 | |
| 60 | // The suite's brakes. Ceilings, not budgets: bursts absorb honest spikes |
| 61 | // (pasting a few lines, inviting a family), the refill rates are what a flood |
| 62 | // runs into. |
| 63 | type modBrakes struct { |
| 64 | // messages: burst 30, ~30/min sustained — chatty humans never notice; |
| 65 | // a script spraying a group does. |
| 66 | messages *modLimiter |
| 67 | // new DMs opened: burst 8, ~10/hour — the mass-DM spam pattern. |
| 68 | dms *modLimiter |
| 69 | // fresh members get a tighter DM bucket for their first day (probation): |
| 70 | // burst 3, ~3/hour. The classic spam-join opens DMs immediately. |
| 71 | dmsNew *modLimiter |
| 72 | // member invites minted: burst 10, ~20/day. |
| 73 | invites *modLimiter |
| 74 | } |
| 75 | |
| 76 | func newModBrakes() *modBrakes { |
| 77 | return &modBrakes{ |
| 78 | messages: newModLimiter(30, 0.5), |
| 79 | dms: newModLimiter(8, 10.0/3600), |
| 80 | dmsNew: newModLimiter(3, 3.0/3600), |
| 81 | invites: newModLimiter(10, 20.0/86400), |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | // probationWindow is how long a fresh member gets the tighter DM bucket. |
| 86 | const probationWindow = 24 * time.Hour |
| 87 | |
| 88 | // allowDM picks the probation bucket for members younger than a day. |
| 89 | func (s *Server) allowDM(uid string, createdAt int64) bool { |
| 90 | if time.Since(time.Unix(createdAt, 0)) < probationWindow { |
| 91 | return s.brake().dmsNew.allow(uid) |
| 92 | } |
| 93 | return s.brake().dms.allow(uid) |
| 94 | } |
| 95 | |