package main // Content-blind spam brakes (issue #578): per-user token buckets on the few // endpoints a flood abuses — messages, new DMs, invites. The server enforces // them without reading anything (pure metadata), admins are exempt, and the // ceilings are far above any human pace so a legitimate member never meets // them. Ported from home/main.go's per-IP limiter, keyed by user id here. import ( "sync" "time" ) // modLimiter is a per-key token bucket. Buckets refill continuously and idle // ones are swept when the map grows, so memory stays bounded. type modLimiter struct { mu sync.Mutex buckets map[string]*modBucket burst float64 perSec float64 } type modBucket struct { tokens float64 last time.Time } func newModLimiter(burst, perSec float64) *modLimiter { return &modLimiter{buckets: map[string]*modBucket{}, burst: burst, perSec: perSec} } func (rl *modLimiter) allow(key string) bool { rl.mu.Lock() defer rl.mu.Unlock() now := time.Now() if len(rl.buckets) > 10000 { for k, b := range rl.buckets { if b.tokens >= rl.burst || now.Sub(b.last) > time.Hour { delete(rl.buckets, k) } } } b, ok := rl.buckets[key] if !ok { b = &modBucket{tokens: rl.burst, last: now} rl.buckets[key] = b } b.tokens += now.Sub(b.last).Seconds() * rl.perSec if b.tokens > rl.burst { b.tokens = rl.burst } b.last = now if b.tokens < 1 { return false } b.tokens-- return true } // The suite's brakes. Ceilings, not budgets: bursts absorb honest spikes // (pasting a few lines, inviting a family), the refill rates are what a flood // runs into. type modBrakes struct { // messages: burst 30, ~30/min sustained — chatty humans never notice; // a script spraying a group does. messages *modLimiter // new DMs opened: burst 8, ~10/hour — the mass-DM spam pattern. dms *modLimiter // fresh members get a tighter DM bucket for their first day (probation): // burst 3, ~3/hour. The classic spam-join opens DMs immediately. dmsNew *modLimiter // member invites minted: burst 10, ~20/day. invites *modLimiter } func newModBrakes() *modBrakes { return &modBrakes{ messages: newModLimiter(30, 0.5), dms: newModLimiter(8, 10.0/3600), dmsNew: newModLimiter(3, 3.0/3600), invites: newModLimiter(10, 20.0/86400), } } // probationWindow is how long a fresh member gets the tighter DM bucket. const probationWindow = 24 * time.Hour // allowDM picks the probation bucket for members younger than a day. func (s *Server) allowDM(uid string, createdAt int64) bool { if time.Since(time.Unix(createdAt, 0)) < probationWindow { return s.brake().dmsNew.allow(uid) } return s.brake().dms.allow(uid) }