package web import ( "fmt" "io" "log" "net/http" "net/http/httptest" "net/url" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "golang.org/x/crypto/bcrypt" ) // rateLimitTestServer builds a minimal Server with a real password hash and an initialized // loginAttempts map, templates loaded (renderLogin needs s.tmpl). No agent/backup/settings — the // re-auth rate limiter fires before any of those. func rateLimitTestServer(t *testing.T) *Server { t.Helper() cfg := &config.Config{} hash, _ := bcrypt.GenerateFromPassword([]byte("correct-pass"), bcrypt.MinCost) cfg.Web.PasswordHash = string(hash) s := &Server{cfg: cfg, logger: log.New(io.Discard, "", 0), version: "test", loginAttempts: map[string]*loginAttempt{}, sessions: map[string]*session{}} s.loadTemplates() return s } func doLogin(s *Server, remoteAddr, xff, password string) *httptest.ResponseRecorder { form := url.Values{"password": {password}} r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.RemoteAddr = remoteAddr if xff != "" { r.Header.Set("X-Forwarded-For", xff) } w := httptest.NewRecorder() s.handleLogin(w, r) return w } func ex(s string) string { if len(s) > 200 { return s[:200] } return s } // Scenario A (the F-B fix): 6 failed logins from ONE host over DISTINCT ephemeral ports and NO // X-Forwarded-For must share a rate-limit key and trip the limiter on attempt 6. Pre-fix (raw // RemoteAddr key) each port is its own key → never limited. This is the F-B red-proof anchor. func TestLoginRateLimit_DirectDistinctPorts_Limited(t *testing.T) { s := rateLimitTestServer(t) var last string for i := 1; i <= 6; i++ { last = doLogin(s, fmt.Sprintf("127.0.0.1:%d", 5000+i), "", "wrong").Body.String() if i < 6 && !strings.Contains(last, "Hibás jelszó") { t.Fatalf("attempt %d expected Hibás jelszó, got: %s", i, ex(last)) } } if !strings.Contains(last, "Túl sok sikertelen") { t.Fatalf("attempt 6 (distinct ports, no XFF) MUST be rate-limited; got: %s", ex(last)) } } // Scenario B (regression): stable X-Forwarded-For across 6 attempts still limits on 6. func TestLoginRateLimit_StableXFF_Limited(t *testing.T) { s := rateLimitTestServer(t) var last string for i := 1; i <= 6; i++ { last = doLogin(s, fmt.Sprintf("10.9.9.9:%d", 5000+i), "203.0.113.9", "wrong").Body.String() } if !strings.Contains(last, "Túl sok sikertelen") { t.Fatalf("attempt 6 with a stable XFF MUST be rate-limited; got: %s", ex(last)) } } // Scenario C (documented accepted limitation): rotating the X-Forwarded-For first hop evades the // per-IP counter. This is NOT what the fix targets (XFF is attacker-controlled on a direct path); // the test pins the known behavior so a future XFF-trust change is a conscious decision. func TestLoginRateLimit_RotatingXFF_NotLimited(t *testing.T) { s := rateLimitTestServer(t) var last string for i := 1; i <= 6; i++ { last = doLogin(s, "10.9.9.9:5000", fmt.Sprintf("203.0.113.%d", i), "wrong").Body.String() } if strings.Contains(last, "Túl sok sikertelen") { t.Fatalf("rotating XFF is a known evasion (out of scope) — expected NOT limited") } if !strings.Contains(last, "Hibás jelszó") { t.Fatalf("expected Hibás jelszó on rotating XFF; got: %s", ex(last)) } } // Scenario D: the escrow wizard re-auth path shares the SAME fixed key (proves the shared clientIP // helper reached escrow_handlers, not just handleLogin). 6 wrong wizard passwords over distinct // ports → 429 on attempt 6. func TestEscrowReauthRateLimit_SharesFixedKey(t *testing.T) { s := rateLimitTestServer(t) var code int for i := 1; i <= 6; i++ { form := url.Values{"password": {"wrong"}} r := httptest.NewRequest(http.MethodPost, "/api/escrow/start", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 6000+i) w := httptest.NewRecorder() s.escrowStartAPIHandler(w, r) code = w.Code } if code != http.StatusTooManyRequests { t.Fatalf("escrow re-auth attempt 6 (distinct ports, no XFF) MUST be 429; got %d", code) } } // Scenario E: a successful login clears the counter for that host key. func TestLoginRateLimit_SuccessClearsCounter(t *testing.T) { s := rateLimitTestServer(t) for i := 1; i <= 4; i++ { doLogin(s, fmt.Sprintf("127.0.0.1:%d", 7000+i), "", "wrong") } if s.loginAttempts["127.0.0.1"] == nil || s.loginAttempts["127.0.0.1"].count != 4 { t.Fatalf("expected 4 accrued failures on the shared key before success") } doLogin(s, "127.0.0.1:7099", "", "correct-pass") // success if s.loginAttempts["127.0.0.1"] != nil { t.Fatalf("a successful login must clear the host's failure counter") } } // clientIP unit: port stripped; XFF first-hop wins; no-port and IPv6 handled. func TestClientIP_StripsPort(t *testing.T) { cases := []struct{ remote, xff, want string }{ {"127.0.0.1:5001", "", "127.0.0.1"}, {"127.0.0.1:5002", "203.0.113.9", "203.0.113.9"}, {"[::1]:443", "", "::1"}, {"192.168.0.5", "", "192.168.0.5"}, // no port → raw {"10.0.0.1:80", "198.51.100.7, 203.0.113.9", "198.51.100.7"}, } for _, c := range cases { r := httptest.NewRequest(http.MethodGet, "/", nil) r.RemoteAddr = c.remote if c.xff != "" { r.Header.Set("X-Forwarded-For", c.xff) } if got := clientIP(r); got != c.want { t.Errorf("clientIP(remote=%q xff=%q) = %q, want %q", c.remote, c.xff, got, c.want) } } }