fix(tracker-go): key login rate limiter on X-Real-IP, not spoofable XFF
nginx's X-Forwarded-For is $proxy_add_x_forwarded_for, which only appends to whatever the client sends — a forged leftmost hop passed straight through, letting an attacker rotate spoofed values to dodge the 5s login cooldown. X-Real-IP is set by nginx to $remote_addr on every proxied location and can't be forged by the client, so key on that instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
db534ea389
commit
5f7b4dcd55
2 changed files with 49 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
35
go-services/tracker-go/website_test.go
Normal file
35
go-services/tracker-go/website_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue