diff --git a/backend/session.go b/backend/session.go index a6fbffc..70caf91 100644 --- a/backend/session.go +++ b/backend/session.go @@ -5,9 +5,11 @@ import ( "crypto/sha256" "crypto/subtle" "encoding/base64" + "net" "net/http" "strconv" "strings" + "sync" "time" ) @@ -87,3 +89,93 @@ func clearSessionCookie(w http.ResponseWriter, r *http.Request) { SameSite: http.SameSiteLaxMode, }) } + +const ( + loginMaxFailures = 10 + loginWindow = 20 * time.Minute +) + +// clientIP returns the address the reverse proxy actually observed. +// +// Traefik appends the peer address to whatever X-Forwarded-For the client sent, +// so the leftmost entry is attacker-controlled and the rightmost is not. Go's +// Header.Get would only read the first header line, which a client can preempt +// by sending its own; Values covers every line so the true last hop is found. +// RemoteAddr is useless behind the proxy — it is always the Traefik container — +// so it serves only as the direct-connection fallback for local development. +func clientIP(r *http.Request) string { + if vals := r.Header.Values("X-Forwarded-For"); len(vals) > 0 { + hops := strings.Split(vals[len(vals)-1], ",") + if ip := strings.TrimSpace(hops[len(hops)-1]); ip != "" { + return ip + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// loginLimiter throttles password guessing: loginMaxFailures failures inside a +// rolling loginWindow blocks further attempts from that IP until the oldest one +// ages out. There is no permanent ban and no unlock step. +// +// Behind carrier-grade NAT this budget is shared with every other subscriber on +// the same public address, so a stranger can lock the owner out for up to one +// window. That is accepted: the block self-heals, and ten attempts is generous +// for a mistyped password. +// +// State is in memory and per-process, so a restart clears it. Entries are +// pruned lazily on access; for a single-user deployment the map cannot grow +// past the handful of addresses that ever attempt a login. +type loginLimiter struct { + mu sync.Mutex + failures map[string][]time.Time +} + +func newLoginLimiter() *loginLimiter { + return &loginLimiter{failures: make(map[string][]time.Time)} +} + +// retryAfter returns how long ip must wait, or zero when it may try now. +func (l *loginLimiter) retryAfter(ip string, now time.Time) time.Duration { + l.mu.Lock() + defer l.mu.Unlock() + + recent := l.pruneLocked(ip, now) + if len(recent) < loginMaxFailures { + return 0 + } + return recent[0].Add(loginWindow).Sub(now) +} + +func (l *loginLimiter) fail(ip string, now time.Time) { + l.mu.Lock() + defer l.mu.Unlock() + l.failures[ip] = append(l.pruneLocked(ip, now), now) +} + +func (l *loginLimiter) reset(ip string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.failures, ip) +} + +// pruneLocked drops attempts older than the window and returns what is left. +// The caller must hold l.mu. +func (l *loginLimiter) pruneLocked(ip string, now time.Time) []time.Time { + cutoff := now.Add(-loginWindow) + kept := l.failures[ip][:0] + for _, at := range l.failures[ip] { + if at.After(cutoff) { + kept = append(kept, at) + } + } + if len(kept) == 0 { + delete(l.failures, ip) + return nil + } + l.failures[ip] = kept + return kept +} diff --git a/backend/session_test.go b/backend/session_test.go index 0a6e761..dd1a9ae 100644 --- a/backend/session_test.go +++ b/backend/session_test.go @@ -128,3 +128,97 @@ func TestClearSessionCookie(t *testing.T) { t.Fatalf("cleared cookie MaxAge = %d, want negative", cookies[0].MaxAge) } } + +func TestClientIP(t *testing.T) { + cases := []struct { + name string + remoteAddr string + xff []string + want string + }{ + {"no header falls back to remote addr", "203.0.113.9:5555", nil, "203.0.113.9"}, + {"single proxy hop", "10.0.0.1:5555", []string{"203.0.113.9"}, "203.0.113.9"}, + { + // The client sent "1.2.3.4" itself; Traefik appended the address it + // actually saw. Only the rightmost entry is trustworthy. + name: "spoofed left entry is ignored", + remoteAddr: "10.0.0.1:5555", + xff: []string{"1.2.3.4, 203.0.113.9"}, + want: "203.0.113.9", + }, + { + name: "spoofed separate header line is ignored", + remoteAddr: "10.0.0.1:5555", + xff: []string{"1.2.3.4", "203.0.113.9"}, + want: "203.0.113.9", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/login", nil) + r.RemoteAddr = tc.remoteAddr + for _, v := range tc.xff { + r.Header.Add("X-Forwarded-For", v) + } + if got := clientIP(r); got != tc.want { + t.Fatalf("clientIP() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestLoginLimiterBlocksAfterMaxFailures(t *testing.T) { + l := newLoginLimiter() + now := time.Now() + for i := 0; i < loginMaxFailures; i++ { + if wait := l.retryAfter("1.2.3.4", now); wait != 0 { + t.Fatalf("blocked after %d failures, want block only after %d", i, loginMaxFailures) + } + l.fail("1.2.3.4", now) + } + wait := l.retryAfter("1.2.3.4", now) + if wait <= 0 { + t.Fatalf("retryAfter = %v after %d failures, want > 0", wait, loginMaxFailures) + } + if wait > loginWindow { + t.Fatalf("retryAfter = %v, want <= %v", wait, loginWindow) + } +} + +func TestLoginLimiterWindowExpires(t *testing.T) { + l := newLoginLimiter() + start := time.Now() + for i := 0; i < loginMaxFailures; i++ { + l.fail("1.2.3.4", start) + } + if l.retryAfter("1.2.3.4", start) == 0 { + t.Fatal("expected block immediately after the failures") + } + later := start.Add(loginWindow + time.Second) + if wait := l.retryAfter("1.2.3.4", later); wait != 0 { + t.Fatalf("retryAfter = %v once the window passed, want 0", wait) + } +} + +func TestLoginLimiterResetClearsCounter(t *testing.T) { + l := newLoginLimiter() + now := time.Now() + for i := 0; i < loginMaxFailures; i++ { + l.fail("1.2.3.4", now) + } + l.reset("1.2.3.4") + if wait := l.retryAfter("1.2.3.4", now); wait != 0 { + t.Fatalf("retryAfter = %v after reset, want 0", wait) + } +} + +func TestLoginLimiterIsPerIP(t *testing.T) { + l := newLoginLimiter() + now := time.Now() + for i := 0; i < loginMaxFailures; i++ { + l.fail("1.2.3.4", now) + } + if wait := l.retryAfter("5.6.7.8", now); wait != 0 { + t.Fatalf("retryAfter for a different IP = %v, want 0", wait) + } +}