diff --git a/chessfut-be/adapter/http/handler.go b/chessfut-be/adapter/http/handler.go index 366b160..f3e9fad 100644 --- a/chessfut-be/adapter/http/handler.go +++ b/chessfut-be/adapter/http/handler.go @@ -2,8 +2,10 @@ package http import ( "encoding/json" + "errors" "log/slog" "net/http" + "regexp" "strconv" "github.com/fayupable/chessfut-be/application/port/input" @@ -16,6 +18,19 @@ const ( maxSearchLimit = 50 ) +// usernamePattern matches chess.com's actual username rules — letters, +// digits, underscores and hyphens only. Anything else (dots, slashes, etc.) +// can never be a real chess.com account, so rejecting it here means scanner +// noise (.env, phpinfo.php, ...) never touches the chess.com rate limiter. +// +// Without this, mass-scanned junk paths queue up on the single, global +// chess.com rate limiter (1 req/s) right alongside real users — a scanner +// probing hundreds of fake usernames could delay legitimate card lookups by +// minutes even though chess.com itself is never at risk of being flooded. +var usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{2,30}$`) + +var errInvalidUsername = errors.New("invalid username format") + type CardHandler struct { getFastCard input.GetFastCardUseCase getDetailedCard input.GetDetailedCardUseCase @@ -42,6 +57,10 @@ func NewCardHandler( func (h *CardHandler) GetFastCard(w http.ResponseWriter, r *http.Request) { username := r.PathValue("username") + if !usernamePattern.MatchString(username) { + writeError(w, http.StatusBadRequest, errInvalidUsername, "invalid username format") + return + } card, err := h.getFastCard.Execute(r.Context(), username) if err != nil { @@ -54,6 +73,10 @@ func (h *CardHandler) GetFastCard(w http.ResponseWriter, r *http.Request) { func (h *CardHandler) GetDetailedCard(w http.ResponseWriter, r *http.Request) { username := r.PathValue("username") + if !usernamePattern.MatchString(username) { + writeError(w, http.StatusBadRequest, errInvalidUsername, "invalid username format") + return + } card, err := h.getDetailedCard.Execute(r.Context(), username) if err != nil { diff --git a/chessfut-be/adapter/http/ratelimit.go b/chessfut-be/adapter/http/ratelimit.go index 44196a5..34cb664 100644 --- a/chessfut-be/adapter/http/ratelimit.go +++ b/chessfut-be/adapter/http/ratelimit.go @@ -8,9 +8,18 @@ import ( "golang.org/x/time/rate" ) +const ( + violationThreshold = 20 + violationWindow = 10 * time.Minute + banDuration = 24 * time.Hour +) + type visitor struct { - limiter *rate.Limiter - lastSeen time.Time + limiter *rate.Limiter + lastSeen time.Time + violations int + violationAt time.Time + bannedUntil time.Time } type RateLimiter struct { @@ -30,28 +39,66 @@ func NewRateLimiter(r rate.Limit, burst int) *RateLimiter { return rl } -func (rl *RateLimiter) getLimiter(ip string) *rate.Limiter { +func (rl *RateLimiter) getVisitor(ip string) *visitor { rl.mu.Lock() defer rl.mu.Unlock() v, exists := rl.visitors[ip] if !exists { - limiter := rate.NewLimiter(rl.rate, rl.burst) - rl.visitors[ip] = &visitor{limiter: limiter, lastSeen: time.Now()} - return limiter + v = &visitor{limiter: rate.NewLimiter(rl.rate, rl.burst), lastSeen: time.Now()} + rl.visitors[ip] = v + return v } v.lastSeen = time.Now() - return v.limiter + return v +} + +func (rl *RateLimiter) isBanned(ip string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + v, exists := rl.visitors[ip] + if !exists { + return false + } + return time.Now().Before(v.bannedUntil) +} + +// recordViolation tracks malformed/rejected requests per IP. An IP that +// racks up too many in a short window (a scanner probing for .env/.php +// paths, for example) gets banned outright for 24 hours instead of just +// throttled — this frees up the shared chess.com rate-limit budget for +// legitimate users instead of making them queue behind scanner noise. +func (rl *RateLimiter) recordViolation(ip string) { + rl.mu.Lock() + defer rl.mu.Unlock() + + v, exists := rl.visitors[ip] + if !exists { + v = &visitor{limiter: rate.NewLimiter(rl.rate, rl.burst), lastSeen: time.Now()} + rl.visitors[ip] = v + } + + if time.Since(v.violationAt) > violationWindow { + v.violations = 0 + } + v.violations++ + v.violationAt = time.Now() + + if v.violations >= violationThreshold { + v.bannedUntil = time.Now().Add(banDuration) + } } // cleanupLoop evicts idle visitors so the map doesn't grow unbounded under -// distributed scraping (many distinct IPs, each hit once). +// distributed scraping (many distinct IPs, each hit once). Banned IPs are +// kept around until their ban actually expires, even if idle. func (rl *RateLimiter) cleanupLoop() { ticker := time.NewTicker(time.Minute) for range ticker.C { rl.mu.Lock() for ip, v := range rl.visitors { - if time.Since(v.lastSeen) > 3*time.Minute { + if time.Since(v.lastSeen) > 3*time.Minute && time.Now().After(v.bannedUntil) { delete(rl.visitors, ip) } } @@ -59,12 +106,35 @@ func (rl *RateLimiter) cleanupLoop() { } } +type statusCapture struct { + http.ResponseWriter + status int +} + +func (s *statusCapture) WriteHeader(status int) { + s.status = status + s.ResponseWriter.WriteHeader(status) +} + func (rl *RateLimiter) Middleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if !rl.getLimiter(clientIP(r)).Allow() { + ip := clientIP(r) + + if rl.isBanned(ip) { + writeErrorMessage(w, http.StatusForbidden, "too many invalid requests — temporarily banned") + return + } + + if !rl.getVisitor(ip).limiter.Allow() { writeErrorMessage(w, http.StatusTooManyRequests, "too many requests") return } - next(w, r) + + sc := &statusCapture{ResponseWriter: w, status: http.StatusOK} + next(sc, r) + + if sc.status == http.StatusBadRequest { + rl.recordViolation(ip) + } } }