Files
whatsapp-mcp-go/whatsapp-bridge/auth/ratelimit.go
T
Atul Singh 193e0d3c77 feat: ratelimit, ghcr package support, media path sensitization, LID to CN migration (#6)
* feat: ratelimit, ghcr package and proper project refactor from askarzh

* fix(bridge): validate media path

* update: dependencies

* fix: added LID to contact number migration
2026-05-13 12:54:31 +02:00

115 lines
2.5 KiB
Go

package auth
import (
"fmt"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/time/rate"
)
// loginLimiter holds per-IP token buckets for /auth/login.
type loginLimiter struct {
mu sync.Mutex
visitors map[string]*visitor
limit rate.Limit
burst int
}
type visitor struct {
limiter *rate.Limiter
lastSeen time.Time
}
// parseRate parses values like "5/1m", "10/30s", "100/1h".
// Returns (rate.Limit, burst, error).
func parseRate(spec string) (rate.Limit, int, error) {
if spec == "" {
return rate.Every(12 * time.Second), 5, nil // default: 5/1m
}
parts := strings.SplitN(spec, "/", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid AUTH_LOGIN_RATE %q: want <count>/<window>", spec)
}
count, err := strconv.Atoi(parts[0])
if err != nil || count <= 0 {
return 0, 0, fmt.Errorf("invalid AUTH_LOGIN_RATE count in %q", spec)
}
window, err := time.ParseDuration(parts[1])
if err != nil || window <= 0 {
return 0, 0, fmt.Errorf("invalid AUTH_LOGIN_RATE window in %q", spec)
}
return rate.Every(window / time.Duration(count)), count, nil
}
func newLoginLimiter(spec string) (*loginLimiter, error) {
limit, burst, err := parseRate(spec)
if err != nil {
return nil, err
}
l := &loginLimiter{
visitors: make(map[string]*visitor),
limit: limit,
burst: burst,
}
go l.evictLoop()
return l, nil
}
func (l *loginLimiter) get(ip string) *rate.Limiter {
l.mu.Lock()
defer l.mu.Unlock()
v, ok := l.visitors[ip]
if !ok {
v = &visitor{limiter: rate.NewLimiter(l.limit, l.burst)}
l.visitors[ip] = v
}
v.lastSeen = time.Now()
return v.limiter
}
func (l *loginLimiter) evictLoop() {
t := time.NewTicker(time.Minute)
defer t.Stop()
for range t.C {
cutoff := time.Now().Add(-10 * time.Minute)
l.mu.Lock()
for ip, v := range l.visitors {
if v.lastSeen.Before(cutoff) {
delete(l.visitors, ip)
}
}
l.mu.Unlock()
}
}
// retryAfterSeconds rounds up to the next whole second the limiter expects to refill.
func retryAfterSeconds(lim *rate.Limiter) int {
r := lim.Reserve()
defer r.Cancel()
d := r.Delay()
if d <= 0 {
return 1
}
secs := int(d / time.Second)
if d%time.Second != 0 {
secs++
}
return secs
}
// clientIP extracts the bare IP (no port) from r.RemoteAddr.
// X-Forwarded-For is intentionally NOT consulted (see spec section 2,
// "Known limitation"). A future PR adds trusted-proxy parsing.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}