package api import ( "net" "net/http" "sync" "time" ) // Throttle limits failed sign-in attempts. // // bcrypt at cost 12 already makes each guess cost ~250 ms, but that is a // per-attempt cost, not a per-attacker one: a hundred parallel guesses is a // hundred parallel bcrypts on a 2 vCPU box, which is both a brute force and a // denial of service on the machine every shop depends on. // // Only FAILURES count. A busy shop where staff sign in all morning is not an // attack, and a limiter that cannot tell the difference gets switched off. // // In memory, not in Postgres: this is one process, and a lockout table would // add a write to the very path an attacker is trying to flood. type Throttle struct { // Max failures within Window before refusing. Max int Window time.Duration mu sync.Mutex hits map[string][]time.Time now func() time.Time } func NewThrottle(max int, window time.Duration) *Throttle { return &Throttle{ Max: max, Window: window, hits: make(map[string][]time.Time), now: func() time.Time { return time.Now() }, } } // Allow reports whether a key may attempt again, without recording anything. func (t *Throttle) Allow(key string) bool { t.mu.Lock() defer t.mu.Unlock() return len(t.live(key)) < t.Max } // Fail records a failed attempt. func (t *Throttle) Fail(key string) { t.mu.Lock() defer t.mu.Unlock() t.hits[key] = append(t.live(key), t.now()) } // Reset clears a key after a success, so one forgotten password in the morning // does not lock somebody out at lunchtime. func (t *Throttle) Reset(key string) { t.mu.Lock() defer t.mu.Unlock() delete(t.hits, key) } // live returns the still-relevant attempts and prunes the rest. Pruning on read // is what keeps the map from growing forever without a sweeper goroutine — // every key that stops being touched stops existing the next time it is. func (t *Throttle) live(key string) []time.Time { cutoff := t.now().Add(-t.Window) kept := t.hits[key][:0] for _, at := range t.hits[key] { if at.After(cutoff) { kept = append(kept, at) } } if len(kept) == 0 { delete(t.hits, key) return nil } t.hits[key] = kept return kept } // Sweep drops keys with nothing live left. Called on a timer so an attacker // spraying a million distinct addresses cannot grow the map without bound // between requests for those same addresses. func (t *Throttle) Sweep() { t.mu.Lock() defer t.mu.Unlock() for k := range t.hits { t.live(k) } } // clientIP prefers the proxy's forwarded address because Traefik terminates // TLS in front of this, so RemoteAddr is always the proxy. // // Trusting X-Forwarded-For is only safe BECAUSE nothing reaches this port // except through that proxy; exposed directly, a client sets the header itself // and defeats the limiter. If the listener ever becomes reachable, this must // change with it. func clientIP(r *http.Request) string { if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { // Left-most is the original client; the rest are proxies. for i := 0; i < len(fwd); i++ { if fwd[i] == ',' { return trim(fwd[:i]) } } return trim(fwd) } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host }