diff --git a/go-services/tracker-go/website.go b/go-services/tracker-go/website.go index f3f9e2cd..3362fdd9 100644 --- a/go-services/tracker-go/website.go +++ b/go-services/tracker-go/website.go @@ -46,12 +46,22 @@ func (s *Server) handleLoginGet(w http.ResponseWriter, r *http.Request) { s.serveStaticFile(w, r, "login.html") } +// loginClientIP returns the rate-limit key for a login attempt. Every nginx +// location proxying to the tracker sets X-Real-IP to $remote_addr (see +// nginx/overlord.conf), which the client cannot forge. X-Forwarded-For is +// $proxy_add_x_forwarded_for — nginx only APPENDS to it, so a client-supplied +// leftmost hop survives untouched and would let an attacker rotate spoofed +// values to dodge the cooldown. +func loginClientIP(r *http.Request) string { + if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); ip != "" { + return ip + } + return clientIP(r) +} + // POST /login — authenticate and set the session cookie (main.py:login). func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) { - ip := clientIP(r) - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - ip = strings.TrimSpace(strings.Split(xff, ",")[0]) - } + ip := loginClientIP(r) if !s.loginLimiter.allow(ip) { writeJSON(w, http.StatusTooManyRequests, map[string]any{"detail": "Too many login attempts. Try again in a few seconds."}) return diff --git a/go-services/tracker-go/website_test.go b/go-services/tracker-go/website_test.go new file mode 100644 index 00000000..8c5e773c --- /dev/null +++ b/go-services/tracker-go/website_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "net/http" + "testing" +) + +// nginx sets X-Real-IP to $remote_addr on every proxied location (see +// nginx/overlord.conf), so it can't be spoofed by the client. XFF, in +// contrast, is $proxy_add_x_forwarded_for — nginx APPENDS to whatever the +// client sent, so an attacker-supplied leftmost value survives untouched. +// The rate limiter must key on X-Real-IP, not XFF. +func TestLoginClientIP_IgnoresSpoofedXFF(t *testing.T) { + r := &http.Request{ + RemoteAddr: "10.0.0.1:12345", // nginx's own connection to the app + Header: http.Header{}, + } + r.Header.Set("X-Real-IP", "203.0.113.5") + r.Header.Set("X-Forwarded-For", "6.6.6.6, 203.0.113.5") // attacker-spoofed leftmost hop + + if got := loginClientIP(r); got != "203.0.113.5" { + t.Errorf("loginClientIP = %q, want %q (trusted X-Real-IP, not spoofable XFF)", got, "203.0.113.5") + } +} + +func TestLoginClientIP_FallsBackToRemoteAddr(t *testing.T) { + r := &http.Request{ + RemoteAddr: "198.51.100.7:54321", + Header: http.Header{}, + } + + if got := loginClientIP(r); got != "198.51.100.7" { + t.Errorf("loginClientIP = %q, want %q (no X-Real-IP, fall back to RemoteAddr)", got, "198.51.100.7") + } +}