aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/hardening_login_limiter_test.go
blob: 1ee08ed0d4e08dc568021f4fc05da409eedceb0d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
package admin

// Regression coverage for the admin HTTP /login failed-login limiter: after a
// handful of failures an IP is refused for a cooldown (even with the right
// password), another client is unaffected, a success clears the state, and the
// tracked-address map stays bounded. Telnet login throttling is out of scope
// (net.Conn/Session expose no client identity), so only the HTTP route is
// exercised here.

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"sync/atomic"
	"testing"
	"time"
)

// loginStatus posts the login form through the real HTTP route and returns the
// raw status: the client must not follow the success redirect, because both a
// rendered error page and a successful login otherwise report 200.
func loginStatus(t *testing.T, srv *httptest.Server, account, password string) int {
	t.Helper()
	client := &http.Client{
		CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
	}
	resp, err := client.PostForm(srv.URL+"/login", url.Values{"account": {account}, "password": {password}})
	if err != nil {
		t.Fatalf("login request: %v", err)
	}
	defer resp.Body.Close()
	return resp.StatusCode
}

// loginStatusFromIP drives the same handler with an explicit client address, so
// a test can act as a second IP without opening a second listener.
func loginStatusFromIP(t *testing.T, s *AdminServer, remoteAddr, account, password string) int {
	t.Helper()
	body := url.Values{"account": {account}, "password": {password}}.Encode()
	req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.RemoteAddr = remoteAddr
	rec := httptest.NewRecorder()
	s.ServeHTTP(rec, req)
	return rec.Code
}

func TestLoginLimiterBlocksFailedLoginsPerIP(t *testing.T) {
	s := newSecurityTestServer(t)
	lim := s.limitLogins()
	base := time.Now()
	var clockOffset atomic.Int64
	lim.now = func() time.Time { return base.Add(time.Duration(clockOffset.Load())) }

	srv := httptest.NewServer(s)
	defer srv.Close()

	fail := func() int { return loginStatus(t, srv, "alice", "wrong") }
	succeed := func() int { return loginStatus(t, srv, "alice", "pw") }

	// Below the threshold a failure still renders the login page with the
	// invalid-credentials message: unchanged response content.
	for i := 0; i < adminLoginMaxFailures-1; i++ {
		if got := fail(); got != http.StatusOK {
			t.Fatalf("attempt %d status = %d, want 200", i+1, got)
		}
	}

	// A successful login clears the failure count, so the next batch starts
	// over instead of tripping the threshold partway through.
	if got := succeed(); got != http.StatusFound {
		t.Fatalf("valid login status = %d, want 302", got)
	}
	for i := 0; i < adminLoginMaxFailures; i++ {
		if got := fail(); got != http.StatusOK {
			t.Fatalf("post-reset attempt %d status = %d, want 200 (failure count was not reset)", i+1, got)
		}
	}

	// Threshold reached: this IP is refused, even with the right password.
	if got := fail(); got != http.StatusTooManyRequests {
		t.Fatalf("status after %d failures = %d, want 429", adminLoginMaxFailures, got)
	}
	if got := succeed(); got != http.StatusTooManyRequests {
		t.Fatalf("valid credentials while blocked = %d, want 429", got)
	}

	// Another client is not punished for this one.
	if got := loginStatusFromIP(t, s, "203.0.113.7:5555", "alice", "pw"); got != http.StatusFound {
		t.Fatalf("valid login from another IP = %d, want 302", got)
	}

	// Once the cooldown lapses the address is served again.
	clockOffset.Store(int64(adminLoginCooldown + time.Minute))
	if got := succeed(); got != http.StatusFound {
		t.Fatalf("valid login after the cooldown = %d, want 302", got)
	}
}

func TestLoginLimiterCooldownAndReset(t *testing.T) {
	base := time.Now()
	now := base
	l := newLoginLimiter()
	l.now = func() time.Time { return now }

	for i := 0; i < adminLoginMaxFailures; i++ {
		l.fail("10.0.0.1")
	}
	if l.allow("10.0.0.1") {
		t.Fatal("address allowed immediately after reaching the failure threshold")
	}
	if !l.allow("10.0.0.2") {
		t.Fatal("an unrelated address was blocked")
	}

	// A blocked address cannot extend its own block, and the cooldown lapses.
	l.fail("10.0.0.1")
	now = base.Add(adminLoginCooldown - time.Second)
	if l.allow("10.0.0.1") {
		t.Fatal("address allowed before the cooldown lapsed")
	}
	now = base.Add(adminLoginCooldown)
	if !l.allow("10.0.0.1") {
		t.Fatal("address still blocked after the cooldown lapsed")
	}

	// A success clears both the count and any remaining cooldown.
	for i := 0; i < adminLoginMaxFailures-1; i++ {
		l.fail("10.0.0.3")
	}
	l.succeed("10.0.0.3")
	for i := 0; i < adminLoginMaxFailures-1; i++ {
		l.fail("10.0.0.3")
	}
	if !l.allow("10.0.0.3") {
		t.Fatal("a successful login did not reset the failure count")
	}
}

func TestLoginLimiterBoundsTrackedAddresses(t *testing.T) {
	base := time.Now()
	l := newLoginLimiter()
	l.now = func() time.Time { return base }

	for i := 0; i < adminLoginMaxTracked*3; i++ {
		l.fail(fmt.Sprintf("198.51.100.%d", i))
	}
	if got := len(l.entries); got > adminLoginMaxTracked {
		t.Fatalf("limiter tracked %d addresses, want at most %d", got, adminLoginMaxTracked)
	}
}

// TestLoginLimiterKeysOnClientIPNotPort pins the key: two ports of one host
// share a bucket, which is what r.RemoteAddr keying is meant to achieve.
func TestLoginLimiterKeysOnClientIPNotPort(t *testing.T) {
	if got := clientIP("127.0.0.1:54321"); got != "127.0.0.1" {
		t.Fatalf("clientIP = %q, want 127.0.0.1", got)
	}
	if got := clientIP("no-port"); got != "no-port" {
		t.Fatalf("clientIP without a port = %q, want the value verbatim", got)
	}
}