diff --git a/backend/README.md b/backend/README.md index f5e4a0df..52027c5d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -57,6 +57,34 @@ If you are running the backend via Docker, the exposed ports are determined by the compose configuration. To use a different port in a Docker environment, you must manually update the docker-compose.yml file to adjust the container’s port mapping. Also, if you change `CCSYNC_PORT`, remember to update `CONTAINER_ORIGIN` accordingly. + ### Rate Limiting and Trusted Proxies + + The backend includes rate limiting that uses the client's IP address. When running behind a reverse proxy (like nginx), you need to configure trusted proxies so the backend correctly identifies client IPs from proxy headers. + + **Automatic Trust:** + - Loopback addresses (127.0.0.1, ::1) are always trusted + - In production (`ENV=production`), Docker bridge networks (172.16.0.0/12) are trusted + + **Manual Configuration:** + + Use `TRUSTED_PROXIES` to specify additional trusted proxy IPs or CIDR ranges: + + ```bash + # Single IP + TRUSTED_PROXIES="10.0.0.1" + + # Multiple IPs (comma-separated) + TRUSTED_PROXIES="10.0.0.1,10.0.0.2" + + # CIDR notation + TRUSTED_PROXIES="10.0.0.0/8,192.168.0.0/16" + ``` + + **When to use:** + - **Local development**: Not needed (loopback is trusted by default) + - **Production with nginx on same server**: Not needed (loopback is trusted) + - **Production with external load balancer**: Set to your load balancer's IP/range + - Run the application: ```bash diff --git a/backend/middleware/ratelimit.go b/backend/middleware/ratelimit.go index 303af010..ad1feaa4 100644 --- a/backend/middleware/ratelimit.go +++ b/backend/middleware/ratelimit.go @@ -2,7 +2,9 @@ package middleware import ( "fmt" + "net" "net/http" + "os" "strings" "sync" "time" @@ -103,23 +105,86 @@ func RateLimitMiddleware(limiter *RateLimiter) func(http.Handler) http.Handler { } } -func getRealIP(r *http.Request) string { - ip := r.Header.Get("X-Real-IP") - if ip != "" { - return ip +// isTrustedProxy checks if the request is from a trusted proxy. +// In production, we trust localhost connections (nginx running on same server). +// The TRUSTED_PROXIES env var can specify additional trusted proxy IPs (comma-separated). +func isTrustedProxy(remoteAddr string) bool { + // Extract IP from remoteAddr (format: "ip:port" or just "ip") + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + host = remoteAddr + } + + // Parse the IP + ip := net.ParseIP(host) + if ip == nil { + return false + } + + // In production, trust loopback (nginx on same server) + if ip.IsLoopback() { + return true } - ip = r.Header.Get("X-Forwarded-For") - if ip != "" { - ips := strings.Split(ip, ",") - if len(ips) > 0 { - return strings.TrimSpace(ips[0]) + // Check against TRUSTED_PROXIES env var + trustedProxies := os.Getenv("TRUSTED_PROXIES") + if trustedProxies != "" { + for _, trusted := range strings.Split(trustedProxies, ",") { + trusted = strings.TrimSpace(trusted) + // Support CIDR notation + if strings.Contains(trusted, "/") { + _, network, err := net.ParseCIDR(trusted) + if err == nil && network.Contains(ip) { + return true + } + } else { + trustedIP := net.ParseIP(trusted) + if trustedIP != nil && trustedIP.Equal(ip) { + return true + } + } + } + } + + // In Docker environments, common bridge networks + // 172.17.0.0/16 is the default Docker bridge network + // 10.0.0.0/8 is commonly used for internal networks + if os.Getenv("ENV") == "production" { + // Trust Docker internal networks in production + dockerBridge := &net.IPNet{ + IP: net.ParseIP("172.16.0.0"), + Mask: net.CIDRMask(12, 32), + } + if dockerBridge.Contains(ip) { + return true + } + } + + return false +} + +func getRealIP(r *http.Request) string { + // Only trust proxy headers if request is from a trusted proxy + if isTrustedProxy(r.RemoteAddr) { + // X-Real-IP is set by nginx + if ip := r.Header.Get("X-Real-IP"); ip != "" { + return ip + } + + // X-Forwarded-For contains a list of IPs, take the first (original client) + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + ips := strings.Split(xff, ",") + if len(ips) > 0 { + return strings.TrimSpace(ips[0]) + } } } - ip = r.RemoteAddr - if idx := strings.Index(ip, ":"); idx != -1 { - ip = ip[:idx] + // For direct connections or untrusted proxies, use RemoteAddr + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + // RemoteAddr might not have a port + return r.RemoteAddr } return ip diff --git a/production/README.md b/production/README.md index 39c625ef..a9a0b885 100644 --- a/production/README.md +++ b/production/README.md @@ -90,6 +90,12 @@ REDIRECT_URL_DEV="https://your-domain.com/auth/callback" SESSION_KEY="$(openssl rand -hex 32)" FRONTEND_ORIGIN_DEV="https://your-domain.com" CONTAINER_ORIGIN="http://syncserver:8080/" +ENV="production" + +# Rate limiting: Trusted proxies for correct client IP detection +# Not needed if nginx runs on the same server (loopback is trusted by default) +# Only set this if using an external load balancer: +# TRUSTED_PROXIES="10.0.0.0/8,192.168.0.0/16" ``` Create `.frontend.env` (see `example.frontend.env`):