Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · server/web/auth_hardening_test.go

Line attribution on main · 433 line(s)

edf9db6e
Keenan Nemetz
13d ago
1 package web
2
3 // Hardening of the authentication surface: what the browser is told to cache,
4 // what HSTS covers, which budget answers a busy client, and what a login for an
5 // account that does not exist looks like.
6
7 import (
8 "crypto/tls"
ad79b7ab
Keenan Nemetz
3d ago
9 "encoding/json"
edf9db6e
Keenan Nemetz
13d ago
10 "fmt"
11 "net/http"
12 "net/http/httptest"
13 "path/filepath"
14 "strings"
15 "testing"
16 "time"
17
d37d301f
Keenan Nemetz
17h ago
18 "rodent.software/vole/forge/access"
19 "rodent.software/vole/forge/config"
20 "rodent.software/vole/forge/identity"
edf9db6e
Keenan Nemetz
13d ago
21 )
22
23 // authWeb is a Web with browser sign-in enabled and a real roster, so the
24 // ceremony handlers have the stores they call without a whole server.
25 func authWeb(t *testing.T) *Web {
26 t.Helper()
27 dir := t.TempDir()
28 path := filepath.Join(dir, "users.db")
29 if err := identity.Init(path); err != nil {
30 t.Fatal(err)
31 }
32 ident, err := identity.Open(path)
33 if err != nil {
34 t.Fatal(err)
35 }
36 t.Cleanup(func() { ident.Close() })
37 ident.AuditPath = filepath.Join(dir, "audit.log")
38 cfg := &config.Config{DataDir: dir, PublicURL: "https://forge.example.com"}
39 w, err := New(nil, nil, ident, nil, cfg)
40 if err != nil {
41 t.Fatal(err)
42 }
43 return w
44 }
45
46 func TestHSTSFollowsTheTransportNotPasskeys(t *testing.T) {
47 t.Parallel()
48 cases := []struct {
49 name string
50 cfg *config.Config
51 tls bool
52 wantHST bool
53 }{
54 {"plain http with no public url", &config.Config{}, false, false},
55 {"http public url", &config.Config{PublicURL: "http://forge.example.com"}, false, false},
56 {"https public url behind a proxy", &config.Config{PublicURL: "https://forge.example.com"}, false, true},
57 {"ACME listener answering plain HTTP", &config.Config{HTTPSAddr: ":443"}, false, false},
58 {"request over TLS", &config.Config{}, true, true},
59 }
60 for _, c := range cases {
61 w := &Web{Cfg: c.cfg}
62 r := httptest.NewRequest("GET", "http://forge.example.com/", nil)
63 if c.tls {
64 r.TLS = &tls.ConnectionState{}
65 }
66 rw := httptest.NewRecorder()
67 w.securityHeaders(rw, r)
68 got := rw.Header().Get("Strict-Transport-Security")
69 if (got != "") != c.wantHST {
70 t.Fatalf("%s: HSTS = %q, want present=%v", c.name, got, c.wantHST)
71 }
72 }
73 }
74
75 // TestAuthPagesAreNotCached: an enrollment page, a sign-in form and an account
76 // page are per-browser state, and a page the browser re-requests from a cache is
77 // a token re-used from a cache.
78 func TestAuthPagesAreNotCached(t *testing.T) {
79 t.Parallel()
80 w := newTestWeb(t)
ed03b3d1
Keenan Nemetz
4h ago
81 for _, path := range []string{"/-/login", "/-/register", "/-/invite"} {
edf9db6e
Keenan Nemetz
13d ago
82 rw := httptest.NewRecorder()
83 switch path {
ed03b3d1
Keenan Nemetz
4h ago
84 case "/-/login":
edf9db6e
Keenan Nemetz
13d ago
85 w.LoginPage(rw, httptest.NewRequest("GET", path, nil))
ed03b3d1
Keenan Nemetz
4h ago
86 case "/-/register":
edf9db6e
Keenan Nemetz
13d ago
87 w.RegisterPage(rw, httptest.NewRequest("GET", path, nil))
ed03b3d1
Keenan Nemetz
4h ago
88 case "/-/invite":
edf9db6e
Keenan Nemetz
13d ago
89 w.InvitePage(rw, httptest.NewRequest("GET", path, nil))
90 }
91 if got := rw.Header().Get("Cache-Control"); got != "no-store" {
92 t.Fatalf("%s: Cache-Control = %q, want no-store", path, got)
93 }
94 if got := rw.Header().Get("Pragma"); got != "no-cache" {
95 t.Fatalf("%s: Pragma = %q, want no-cache", path, got)
96 }
97 if got := rw.Header().Get("Referrer-Policy"); got != "no-referrer" {
98 t.Fatalf("%s: Referrer-Policy = %q, want no-referrer", path, got)
99 }
100 }
101 }
102
103 // TestRateLimiterBudgets is the limiter itself: a window's worth of attempts is
104 // allowed, the next is refused with a wait, and a later window starts over.
105 func TestRateLimiterBudgets(t *testing.T) {
106 t.Parallel()
107 now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
108 l := newRateLimiter(2, time.Minute)
109 l.now = func() time.Time { return now }
110
111 for i := 0; i < 2; i++ {
112 if ok, _ := l.allow("1.2.3.4"); !ok {
113 t.Fatalf("attempt %d was refused inside the budget", i+1)
114 }
115 }
116 ok, wait := l.allow("1.2.3.4")
117 if ok {
118 t.Fatal("the attempt past the budget was allowed")
119 }
120 if wait <= 0 || wait > time.Minute {
121 t.Fatalf("wait = %s, want a positive wait inside the window", wait)
122 }
123 // Another key has its own budget.
124 if ok, _ := l.allow("5.6.7.8"); !ok {
125 t.Fatal("a different address shared another address's budget")
126 }
127 // The window passing resets it.
128 now = now.Add(2 * time.Minute)
129 if ok, _ := l.allow("1.2.3.4"); !ok {
130 t.Fatal("the budget did not reset after the window")
131 }
132 }
133
134 // TestAuthBeginIsRateLimited: the begin endpoint answers 429 with Retry-After once
135 // an address has spent its budget, and the refusal is a JSON error like every
136 // other failure on that endpoint.
137 func TestAuthBeginIsRateLimited(t *testing.T) {
138 t.Parallel()
139 w := authWeb(t)
140 w.limits.beginAddress = newRateLimiter(2, time.Minute)
141
142 attempt := func(addr string) *httptest.ResponseRecorder {
ed03b3d1
Keenan Nemetz
4h ago
143 r := httptest.NewRequest("POST", "/-/auth/login/begin", strings.NewReader(`{"username":"alice"}`))
edf9db6e
Keenan Nemetz
13d ago
144 r.RemoteAddr = addr
145 rw := httptest.NewRecorder()
146 w.LoginBegin(rw, r)
147 return rw
148 }
149 for i := 0; i < 2; i++ {
150 if rw := attempt("203.0.113.9:1234"); rw.Code == http.StatusTooManyRequests {
151 t.Fatalf("attempt %d was limited too early", i+1)
152 }
153 }
154 rw := attempt("203.0.113.9:1234")
155 if rw.Code != http.StatusTooManyRequests {
156 t.Fatalf("status = %d, want 429", rw.Code)
157 }
158 if rw.Header().Get("Retry-After") == "" {
159 t.Fatal("a 429 without Retry-After")
160 }
161 if !strings.Contains(rw.Body.String(), "too many attempts") {
162 t.Fatalf("body = %q", rw.Body.String())
163 }
164 if rw := attempt("198.51.100.7:1234"); rw.Code == http.StatusTooManyRequests {
165 t.Fatal("one address's budget limited another")
166 }
167 }
168
169 // TestLoginBeginDoesNotSayWhetherAnAccountExists: an unknown account gets the
170 // same assertion request a real one does, so the endpoint cannot be asked whether
171 // somebody has an account here.
172 func TestLoginBeginDoesNotSayWhetherAnAccountExists(t *testing.T) {
173 t.Parallel()
174 w := authWeb(t)
ed03b3d1
Keenan Nemetz
4h ago
175 r := httptest.NewRequest("POST", "/-/auth/login/begin", strings.NewReader(`{"username":"nobody"}`))
edf9db6e
Keenan Nemetz
13d ago
176 r.RemoteAddr = "127.0.0.1:1234"
177 rw := httptest.NewRecorder()
178 w.LoginBegin(rw, r)
179 if rw.Code != http.StatusOK {
180 t.Fatalf("unknown account begin = %d %s, want 200 with an assertion request",
181 rw.Code, rw.Body.String())
182 }
183 body := rw.Body.String()
184 if !strings.Contains(body, `"publicKey"`) || !strings.Contains(body, `"allowCredentials"`) {
185 t.Fatalf("the decoy is not shaped like a real assertion request: %s", body)
186 }
187 for _, leak := range []string{"no passkey", "no such", "not found"} {
188 if strings.Contains(body, leak) {
189 t.Fatalf("the response names the account's state: %s", body)
190 }
191 }
192 }
193
194 // TestCeremonyStoreIsBounded: an anonymous client cannot grow the in-flight
195 // ceremony map past its cap, and expired ceremonies do not count against it.
196 func TestCeremonyStoreIsBounded(t *testing.T) {
197 t.Parallel()
198 s := newCeremonyStore()
199 for i := 0; i < maxCeremonies; i++ {
200 if _, err := s.put(ceremony{Kind: "login", Name: "account"}); err != nil {
201 t.Fatalf("put %d: %v", i, err)
202 }
203 }
204 if _, err := s.put(ceremony{Kind: "login", Name: "account"}); err == nil {
205 t.Fatal("the ceremony map grew past its cap")
206 }
207 // Expiring them makes room again, because a put sweeps first.
208 s.mu.Lock()
209 for k, c := range s.m {
210 c.Expires = time.Now().Add(-time.Second)
211 s.m[k] = c
212 }
213 s.mu.Unlock()
214 if _, err := s.put(ceremony{Kind: "login", Name: "account"}); err != nil {
215 t.Fatalf("a swept store refused a ceremony: %v", err)
216 }
217 }
218
219 // TestJSONEndpointsRequireTheCSRFHeader: the header is the only source for the
220 // JSON ceremonies. A form field is not accepted there, so a cross-site form post
221 // cannot carry a token the endpoint would read.
222 func TestJSONEndpointsRequireTheCSRFHeader(t *testing.T) {
223 t.Parallel()
224 w := authWeb(t)
ed03b3d1
Keenan Nemetz
4h ago
225 r := httptest.NewRequest("POST", "/-/account/passkeys/begin", strings.NewReader(`{"label":"x"}`))
edf9db6e
Keenan Nemetz
13d ago
226 r.PostForm = map[string][]string{"csrf": {"from-the-form"}}
227 if w.checkCSRF(r) {
228 t.Fatal("the JSON CSRF check accepted a form field")
229 }
701232cf
Keenan Nemetz
12d ago
230 // The origin check applies to the JSON endpoints too: the same token from a
231 // cross-origin page is refused.
232 r = httptest.NewRequest("POST", "https://forge.example.com/account/passkeys/begin",
233 strings.NewReader(`{"label":"x"}`))
234 r.Header.Set("Origin", "https://evil.example")
235 r.Header.Set("X-Vole-CSRF", "token")
236 if w.checkCSRF(r) {
237 t.Fatal("the JSON CSRF check ignored a foreign origin")
238 }
edf9db6e
Keenan Nemetz
13d ago
239 }
240
241 // TestClientAddressUsesTheTrustedHeader: the address budget keys on the address
242 // the instance is configured to trust, and on the connection when it is not.
243 func TestClientAddressUsesTheTrustedHeader(t *testing.T) {
244 t.Parallel()
ed03b3d1
Keenan Nemetz
4h ago
245 r := httptest.NewRequest("POST", "/-/auth/login/begin", nil)
edf9db6e
Keenan Nemetz
13d ago
246 r.RemoteAddr = "10.0.0.1:5000"
247 r.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
248 if got := (&Web{}).clientAddress(r); got != "10.0.0.1" {
249 t.Fatalf("clientAddress without a configured header = %q, want the connection", got)
250 }
251 w := &Web{Cfg: &config.Config{GitHTTPClientIPHeader: "X-Forwarded-For"}}
252 if got := w.clientAddress(r); got != "203.0.113.9" {
253 t.Fatalf("clientAddress with a trusted header = %q, want the first entry", got)
254 }
255 }
256
257 // TestInviteTokensAreRedactedFromLogs: a request path that used to carry an
258 // enrollment token must not put it in a log line, whatever the log is for.
259 func TestInviteTokensAreRedactedFromLogs(t *testing.T) {
260 t.Parallel()
ed03b3d1
Keenan Nemetz
4h ago
261 if got := loggablePath("/-/invite/" + "secret-token"); got != "/-/invite/[redacted]" {
edf9db6e
Keenan Nemetz
13d ago
262 t.Fatalf("loggablePath kept the token: %q", got)
263 }
ed03b3d1
Keenan Nemetz
4h ago
264 if got := loggablePath("/demo/issues"); got != "/demo/issues" {
edf9db6e
Keenan Nemetz
13d ago
265 t.Fatalf("loggablePath changed an ordinary path: %q", got)
266 }
267 var lines []string
268 w := newTestWeb(t)
269 w.Logger = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }
270 rw := httptest.NewRecorder()
ed03b3d1
Keenan Nemetz
4h ago
271 w.serverError(rw, httptest.NewRequest("GET", "/-/invite/secret-token", nil), "boom")
edf9db6e
Keenan Nemetz
13d ago
272 if len(lines) != 1 {
273 t.Fatalf("logged %d lines, want 1", len(lines))
274 }
275 joined := strings.Join(lines, "\n")
276 if strings.Contains(joined, "secret-token") {
277 t.Fatalf("the log line names the token: %q", joined)
278 }
ed03b3d1
Keenan Nemetz
4h ago
279 if !strings.Contains(joined, "/-/invite/[redacted]") {
edf9db6e
Keenan Nemetz
13d ago
280 t.Fatalf("the log line does not show where the request went: %q", joined)
281 }
282 }
9e8cc155
Keenan Nemetz
12d ago
283
e5542ac6
Keenan Nemetz
12d ago
284 // TestContributorWritesAreBudgeted: an untrusted account's browser writes are
285 // budgeted per hour, and the budget comes from the instance config.
286 func TestContributorWritesAreBudgeted(t *testing.T) {
9e8cc155
Keenan Nemetz
12d ago
287 t.Parallel()
288 limits := newAuthLimits(2)
289 if limits.write.limit != 2 {
290 t.Fatalf("write limit = %d, want 2", limits.write.limit)
291 }
e5542ac6
Keenan Nemetz
12d ago
292 if got := newAuthLimits(0).write.limit; got != defaultContributorWritesPerHour {
293 t.Fatalf("default write limit = %d, want %d", got, defaultContributorWritesPerHour)
9e8cc155
Keenan Nemetz
12d ago
294 }
295 w := &Web{limits: limits}
ed03b3d1
Keenan Nemetz
4h ago
296 req := httptest.NewRequest("POST", "/demo/issues", nil)
9e8cc155
Keenan Nemetz
12d ago
297 for i := 0; i < 2; i++ {
298 if !w.limitForm(httptest.NewRecorder(), req, w.limits.write, "write:alice") {
299 t.Fatalf("write %d was refused inside the budget", i+1)
300 }
301 }
302 rw := httptest.NewRecorder()
303 if w.limitForm(rw, req, w.limits.write, "write:alice") {
304 t.Fatal("a write past the budget was accepted")
305 }
306 if rw.Code != http.StatusTooManyRequests || rw.Header().Get("Retry-After") == "" {
307 t.Fatalf("refusal = %d, Retry-After %q", rw.Code, rw.Header().Get("Retry-After"))
308 }
309 // Another account has its own budget.
310 if !w.limitForm(httptest.NewRecorder(), req, w.limits.write, "write:bob") {
311 t.Fatal("one account's budget limited another")
312 }
313 }
e5542ac6
Keenan Nemetz
12d ago
314
315 // TestOnlyUntrustedWritesAreBudgeted: maintainers and admins are trusted roles in
316 // the instance's threat model — they can change rules, push shared branches and run
317 // the instance — so the contributor budget does not apply to them.
318 func TestOnlyUntrustedWritesAreBudgeted(t *testing.T) {
319 t.Parallel()
320 for role, want := range map[access.Role]bool{
321 access.None: true,
322 access.Read: true,
323 access.Contribute: true,
324 access.Maintain: false,
325 access.Admin: false,
326 } {
327 if got := writeBudgeted(role); got != want {
328 t.Fatalf("writeBudgeted(%s) = %v, want %v", role, got, want)
329 }
330 }
331 }
25343521
Keenan Nemetz
3d ago
332
333 // TestLoginBeginBoundsTheAccountName: an invalid name gets the same decoy as an
334 // unknown account, but it does not reach the rate limiter or ceremony store at
335 // whatever length the client sent.
336 func TestLoginBeginBoundsTheAccountName(t *testing.T) {
337 t.Parallel()
338 w := authWeb(t)
339 huge := strings.Repeat("a", 1<<19)
ed03b3d1
Keenan Nemetz
4h ago
340 r := httptest.NewRequest("POST", "/-/auth/login/begin",
25343521
Keenan Nemetz
3d ago
341 strings.NewReader(`{"username":"`+huge+`"}`))
342 r.RemoteAddr = "127.0.0.1:1234"
343 rw := httptest.NewRecorder()
344 w.LoginBegin(rw, r)
345 if rw.Code != http.StatusOK {
346 t.Fatalf("oversized name = %d %s, want the decoy 200", rw.Code, rw.Body.String())
347 }
348 if !strings.Contains(rw.Body.String(), `"allowCredentials"`) {
349 t.Fatalf("the decoy is not shaped like an assertion request: %s", rw.Body.String())
350 }
351 w.limits.beginAccount.mu.Lock()
352 defer w.limits.beginAccount.mu.Unlock()
353 for key := range w.limits.beginAccount.m {
354 if len(key) > maxRateKeyBytes {
355 t.Fatalf("the limiter stored a %d-byte key, limit %d", len(key), maxRateKeyBytes)
356 }
357 }
358 }
359
360 // TestDynamicPagesArePrivate: a templated page is rendered for the viewer who
361 // asked for it, so a shared cache must not keep it.
362 func TestDynamicPagesArePrivate(t *testing.T) {
363 t.Parallel()
364 w := newTestWeb(t)
365 rw := httptest.NewRecorder()
ed03b3d1
Keenan Nemetz
4h ago
366 w.notFound(rw, httptest.NewRequest("GET", "/example/issues/nope", nil))
25343521
Keenan Nemetz
3d ago
367 if got := rw.Header().Get("Cache-Control"); !strings.Contains(got, "private") {
368 t.Fatalf("Cache-Control = %q, want a private directive", got)
369 }
370 }
ad79b7ab
Keenan Nemetz
3d ago
371
372 // TestLoginBeginSupportsDiscoverableSignIn: an empty account name starts the
373 // usernameless flow, so conditional UI has a challenge and the authenticator can
374 // return the account in the assertion.
375 func TestLoginBeginSupportsDiscoverableSignIn(t *testing.T) {
376 t.Parallel()
377 w := authWeb(t)
ed03b3d1
Keenan Nemetz
4h ago
378 r := httptest.NewRequest("POST", "/-/auth/login/begin", strings.NewReader(`{}`))
ad79b7ab
Keenan Nemetz
3d ago
379 r.RemoteAddr = "127.0.0.1:1234"
380 rw := httptest.NewRecorder()
381 w.LoginBegin(rw, r)
382 if rw.Code != http.StatusOK {
383 t.Fatalf("discoverable begin = %d %s, want 200", rw.Code, rw.Body.String())
384 }
385 var out struct {
386 PublicKey struct {
387 AllowCredentials []any `json:"allowCredentials"`
388 UserVerification string `json:"userVerification"`
389 } `json:"publicKey"`
390 }
391 if err := json.Unmarshal(rw.Body.Bytes(), &out); err != nil {
392 t.Fatalf("decode assertion request: %v", err)
393 }
394 if len(out.PublicKey.AllowCredentials) != 0 {
395 t.Fatalf("discoverable begin named %d credentials, want none", len(out.PublicKey.AllowCredentials))
396 }
397 if out.PublicKey.UserVerification != "required" {
398 t.Fatalf("userVerification = %q, want required", out.PublicKey.UserVerification)
399 }
400 }
401
402 // TestRegistrationRequiresDiscoverableCredentials: a passkey that autofill cannot
403 // find is not a passkey for this product, so new registrations ask for a resident
404 // key and user verification.
405 func TestRegistrationRequiresDiscoverableCredentials(t *testing.T) {
406 t.Parallel()
407 w := authWeb(t)
408 w.Cfg.OpenRegistration = true
ed03b3d1
Keenan Nemetz
4h ago
409 r := httptest.NewRequest("POST", "/-/auth/register/begin", strings.NewReader(`{"username":"alice"}`))
ad79b7ab
Keenan Nemetz
3d ago
410 r.RemoteAddr = "127.0.0.1:1234"
411 rw := httptest.NewRecorder()
412 w.RegisterBegin(rw, r)
413 if rw.Code != http.StatusOK {
414 t.Fatalf("register begin = %d %s, want 200", rw.Code, rw.Body.String())
415 }
416 var out struct {
417 PublicKey struct {
418 AuthenticatorSelection struct {
419 ResidentKey string `json:"residentKey"`
420 UserVerification string `json:"userVerification"`
421 } `json:"authenticatorSelection"`
422 } `json:"publicKey"`
423 }
424 if err := json.Unmarshal(rw.Body.Bytes(), &out); err != nil {
425 t.Fatalf("decode creation options: %v", err)
426 }
427 if out.PublicKey.AuthenticatorSelection.ResidentKey != "required" {
428 t.Fatalf("residentKey = %q, want required", out.PublicKey.AuthenticatorSelection.ResidentKey)
429 }
430 if out.PublicKey.AuthenticatorSelection.UserVerification != "required" {
431 t.Fatalf("userVerification = %q, want required", out.PublicKey.AuthenticatorSelection.UserVerification)
432 }
433 }