From 74a6a0c227239d404e212c134b1ec006e6df316d Mon Sep 17 00:00:00 2001 From: Fayupable <90789180+Fayupable@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:47:19 +0300 Subject: [PATCH] fix: resolve real client IP behind Cloudflare and log rejected requests Behind Cloudflare, the rightmost public address in X-Forwarded-For is a Cloudflare edge, so the 24h auto-ban hit the edge IP and blocked every visitor routed through it. clientIP now skips private addresses, Cloudflare's published ranges and operator-configured proxies (TRUSTED_PROXY_IPS) when walking the chain from the right. - strip the port from RemoteAddr when no forwarded header is present - move LoggingMiddleware outside the rate limiter so 403/429 are logged - add TRUSTED_PROXY_IPS to config and docker-compose Co-Authored-By: Claude Sonnet 5.5 --- chessfut-be/adapter/http/middleware.go | 70 ++++++++++++++++++++++++-- chessfut-be/config/config.go | 17 +++++++ chessfut-be/main.go | 11 ++-- docker-compose.yaml | 1 + 4 files changed, 90 insertions(+), 9 deletions(-) diff --git a/chessfut-be/adapter/http/middleware.go b/chessfut-be/adapter/http/middleware.go index 7adff4f..8681d73 100644 --- a/chessfut-be/adapter/http/middleware.go +++ b/chessfut-be/adapter/http/middleware.go @@ -75,23 +75,85 @@ func writeErrorMessage(w http.ResponseWriter, status int, publicMessage string) writeJSON(w, status, map[string]string{"error": publicMessage}) } +var cloudflareRanges = []string{ + "173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22", + "141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20", + "197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13", + "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22", + "2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32", + "2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32", +} + +var trustedProxyNets = parseCIDRs(cloudflareRanges) + +func parseCIDRs(values []string) []*net.IPNet { + nets := make([]*net.IPNet, 0, len(values)) + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if !strings.Contains(v, "/") { + if ip := net.ParseIP(v); ip != nil { + if ip.To4() != nil { + v += "/32" + } else { + v += "/128" + } + } + } + if _, block, err := net.ParseCIDR(v); err == nil { + nets = append(nets, block) + } + } + return nets +} + +// SetTrustedProxies registers extra proxy addresses (for example the +// server's own public IP, which appears in the forwarded chain when the +// frontend calls the API through Cloudflare) on top of Cloudflare's ranges. +// It must be called once at startup, before the server begins serving. +func SetTrustedProxies(extra []string) { + trustedProxyNets = append(parseCIDRs(cloudflareRanges), parseCIDRs(extra)...) +} + +func isTrustedHop(ip net.IP) bool { + if ip.IsPrivate() || ip.IsLoopback() { + return true + } + for _, block := range trustedProxyNets { + if block.Contains(ip) { + return true + } + } + return false +} + +// clientIP walks X-Forwarded-For from the right, skipping hops we control +// (private/Docker addresses, Cloudflare edges, configured proxies), and +// returns the first address that is not one of ours. Each proxy appends the +// peer it saw to the end, so the right side is the trustworthy side. func clientIP(r *http.Request) string { fwd := r.Header.Get("X-Forwarded-For") if fwd == "" { - return r.RemoteAddr + return remoteHost(r.RemoteAddr) } parts := strings.Split(fwd, ",") for i := len(parts) - 1; i >= 0; i-- { candidate := strings.TrimSpace(parts[i]) ip := net.ParseIP(candidate) - if ip != nil && !isPrivateOrInternalIP(ip) { + if ip != nil && !isTrustedHop(ip) { return candidate } } return strings.TrimSpace(parts[0]) } -func isPrivateOrInternalIP(ip net.IP) bool { - return ip.IsPrivate() || ip.IsLoopback() +func remoteHost(addr string) string { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return addr + } + return host } diff --git a/chessfut-be/config/config.go b/chessfut-be/config/config.go index 30e6bad..504e788 100644 --- a/chessfut-be/config/config.go +++ b/chessfut-be/config/config.go @@ -3,6 +3,7 @@ package config import ( "log" "os" + "strings" "github.com/joho/godotenv" ) @@ -16,6 +17,7 @@ type Config struct { ChessComUserAgent string ChessComBaseURL string MigrationsPath string + TrustedProxies []string } func Load() Config { @@ -30,6 +32,7 @@ func Load() Config { ChessComUserAgent: mustGetEnv("CHESSCOM_USER_AGENT"), ChessComBaseURL: mustGetEnv("CHESSCOM_BASE_URL"), MigrationsPath: getEnv("MIGRATIONS_PATH", "db/migration"), + TrustedProxies: splitCSV(getEnv("TRUSTED_PROXY_IPS", "")), } } @@ -47,3 +50,17 @@ func mustGetEnv(key string) string { } return v } + +func splitCSV(v string) []string { + if v == "" { + return nil + } + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/chessfut-be/main.go b/chessfut-be/main.go index fe6c53a..5356367 100644 --- a/chessfut-be/main.go +++ b/chessfut-be/main.go @@ -26,6 +26,7 @@ func main() { slog.SetDefault(logger) cfg := config.Load() + httpadapter.SetTrustedProxies(cfg.TrustedProxies) ctx := context.Background() if err := postgres.RunMigrations(cfg.DatabaseURL, cfg.MigrationsPath); err != nil { @@ -73,11 +74,11 @@ func main() { adminHandler := httpadapter.NewAdminHandler(refreshCard, syncTitledPlayers, refreshStaleCards) mux := http.NewServeMux() - mux.HandleFunc("GET /api/v1/player/{username}", httpadapter.CORSMiddleware(publicRateLimiter.Middleware(httpadapter.LoggingMiddleware(cardHandler.GetFastCard)))) - mux.HandleFunc("GET /api/v1/player/{username}/detailed", httpadapter.CORSMiddleware(publicRateLimiter.Middleware(httpadapter.LoggingMiddleware(cardHandler.GetDetailedCard)))) - mux.HandleFunc("GET /api/v1/leaderboard", httpadapter.CORSMiddleware(publicRateLimiter.Middleware(httpadapter.LoggingMiddleware(cardHandler.GetLeaderboard)))) - mux.HandleFunc("GET /api/v1/stats", httpadapter.CORSMiddleware(publicRateLimiter.Middleware(httpadapter.LoggingMiddleware(cardHandler.GetStats)))) - mux.HandleFunc("GET /api/v1/search", httpadapter.CORSMiddleware(publicRateLimiter.Middleware(httpadapter.LoggingMiddleware(cardHandler.SearchPlayers)))) + mux.HandleFunc("GET /api/v1/player/{username}", httpadapter.CORSMiddleware(httpadapter.LoggingMiddleware(publicRateLimiter.Middleware(cardHandler.GetFastCard)))) + mux.HandleFunc("GET /api/v1/player/{username}/detailed", httpadapter.CORSMiddleware(httpadapter.LoggingMiddleware(publicRateLimiter.Middleware(cardHandler.GetDetailedCard)))) + mux.HandleFunc("GET /api/v1/leaderboard", httpadapter.CORSMiddleware(httpadapter.LoggingMiddleware(publicRateLimiter.Middleware(cardHandler.GetLeaderboard)))) + mux.HandleFunc("GET /api/v1/stats", httpadapter.CORSMiddleware(httpadapter.LoggingMiddleware(publicRateLimiter.Middleware(cardHandler.GetStats)))) + mux.HandleFunc("GET /api/v1/search", httpadapter.CORSMiddleware(httpadapter.LoggingMiddleware(publicRateLimiter.Middleware(cardHandler.SearchPlayers)))) mux.HandleFunc("POST /api/admin/refresh/{username}", httpadapter.LoggingMiddleware(httpadapter.AdminAuthMiddleware(cfg.AdminAPIKey, adminHandler.RefreshCard))) mux.HandleFunc("POST /api/admin/sync-titled", httpadapter.LoggingMiddleware(httpadapter.AdminAuthMiddleware(cfg.AdminAPIKey, adminHandler.SyncTitledPlayers))) diff --git a/docker-compose.yaml b/docker-compose.yaml index 3a73c1c..09de05e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -59,6 +59,7 @@ services: REDIS_ADDR: "redis:6379" REDIS_PASSWORD: "${REDIS_PASSWORD}" ADMIN_API_KEY: "${ADMIN_API_KEY:?ADMIN_API_KEY is required}" + TRUSTED_PROXY_IPS: "${TRUSTED_PROXY_IPS:-}" CHESSCOM_USER_AGENT: "${CHESSCOM_USER_AGENT:-chessfut/1.0 (contact: your-email@example.com)}" CHESSCOM_BASE_URL: "${CHESSCOM_BASE_URL:-https://api.chess.com/pub}" VIRTUAL_HOST: "api.chessfut.com"