Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions chessfut-be/adapter/http/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package http

import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"regexp"
"strconv"

"github.com/fayupable/chessfut-be/application/port/input"
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
92 changes: 81 additions & 11 deletions chessfut-be/adapter/http/ratelimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -30,41 +39,102 @@ 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)
}
}
rl.mu.Unlock()
}
}

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)
}
}
}
Loading