oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "maps"
8 "net/http"
9 "net/http/httptest"
10 "net/url"
11 "slices"
12 "strings"
13 "sync"
14 "testing"
15
16 "github.com/keymaildev/signin"
17
18 "amadan.net/oficina/home/internal/home/sealtest"
19)
20
21// signIn posts the sign-in form.
22//
23// It sends an Origin header because csrf.Protect is mounted router-wide
24// and refuses a POST without origin evidence — before chi matches, so
25// without it every test here would fail for the wrong reason.
26func signIn(t *testing.T, app *testApp, addr string) *httptest.ResponseRecorder {
27 t.Helper()
28 form := url.Values{"address": {addr}}
29 req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode()))
30 req.Host = credentialHost
31 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
32 req.Header.Set("Origin", Origin)
33 w := httptest.NewRecorder()
34 app.Router().ServeHTTP(w, req)
35 return w
36}
37
38// capturedLink returns the token from the most recent magic link.
39func capturedLink(t *testing.T, app *testApp) string {
40 t.Helper()
41 sent, ok := app.mailer.last()
42 if !ok {
43 t.Fatal("no mail was sent")
44 }
45 i := strings.Index(sent.Body, Origin+linkPath)
46 if i < 0 {
47 t.Fatalf("no sign-in link in the mail body: %q", sent.Body)
48 }
49 rest := sent.Body[i:]
50 if j := strings.IndexAny(rest, " \r\n\t\""); j >= 0 {
51 rest = rest[:j]
52 }
53 u, err := url.Parse(rest)
54 if err != nil {
55 t.Fatalf("parse link %q: %v", rest, err)
56 }
57 token := u.Query().Get("token")
58 if token == "" {
59 t.Fatalf("link %q carries no token", rest)
60 }
61 return token
62}
63
64// redeem posts the confirmation form.
65func redeem(t *testing.T, app *testApp, token string) *httptest.ResponseRecorder {
66 t.Helper()
67 form := url.Values{"token": {token}}
68 req := httptest.NewRequest(http.MethodPost, linkPath, strings.NewReader(form.Encode()))
69 req.Host = credentialHost
70 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
71 req.Header.Set("Origin", Origin)
72 w := httptest.NewRecorder()
73 app.Router().ServeHTTP(w, req)
74 return w
75}
76
77func sessionCookie(rec *httptest.ResponseRecorder) *http.Cookie {
78 for _, c := range (&http.Response{Header: rec.Header()}).Cookies() {
79 if strings.Contains(c.Name, "session") && c.Value != "" {
80 return c
81 }
82 }
83 return nil
84}
85
86// TestFullSigninLeavesNoReadableAddress is §12 gate 2: a full sign-in
87// leaves no readable address anywhere, WAL included. The directory and
88// owner seed now extends the completed sign-in into separate role databases.
89// The rekey half remains for its implementation.
90func TestFullSigninLeavesNoReadableAddress(t *testing.T) {
91 app := newTestApp(t)
92
93 if rec := signIn(t, app, sealtest.CanaryAddress); rec.Code != http.StatusSeeOther {
94 t.Fatalf("POST /signin = %d, want 303", rec.Code)
95 }
96 if rec := redeem(t, app, capturedLink(t, app)); rec.Code != http.StatusSeeOther {
97 t.Fatalf("redeem = %d, want 303", rec.Code)
98 }
99
100 exerciseOperatorSeedPrivacy(t, app)
101 sealtest.AssertNoPlaintext(t, app.dbPath)
102}
103
104// A sign-in that completes must actually mint a session, or the gate
105// above would pass on an app that stores nothing because it does
106// nothing.
107func TestSigninMintsASessionOnThePersonRef(t *testing.T) {
108 app := newTestApp(t)
109 signIn(t, app, "person@example.invalid")
110 rec := redeem(t, app, capturedLink(t, app))
111
112 if sessionCookie(rec) == nil {
113 t.Fatal("redemption set no session cookie")
114 }
115
116 var subject string
117 if err := app.db.Writer().QueryRow("SELECT subject FROM sessions").Scan(&subject); err != nil {
118 t.Fatalf("no session row: %v", err)
119 }
120 if !ValidPersonRef(subject) {
121 t.Fatalf("session subject is %q, which is not a person ref", subject)
122 }
123}
124
125// Classification fails open: a resolver failure must still send a magic
126// link, never a dead end.
127func TestFailedClassificationStillSendsAMagicLink(t *testing.T) {
128 app := newTestApp(t)
129 app.flow.Classifier.LookupTXT = func(context.Context, string) ([]string, error) {
130 return nil, errors.New("servfail")
131 }
132 next, err := app.flow.Begin(context.Background(), "someone@example.invalid", "", signin.Method(""))
133 if err != nil {
134 t.Fatalf("Begin: %v", err)
135 }
136 if next.Kind != signin.NextMagicLink {
137 t.Fatalf("Kind = %v, want NextMagicLink: classification must fail open", next.Kind)
138 }
139}
140
141// A returning person gets the SAME person ref. Without the blind index
142// every sign-in minted a new one, so nobody could ever return.
143func TestSecondSigninFindsTheSamePerson(t *testing.T) {
144 app := newTestApp(t)
145
146 signIn(t, app, "person@example.invalid")
147 redeem(t, app, capturedLink(t, app))
148 signIn(t, app, "Person@Example.Invalid")
149 redeem(t, app, capturedLink(t, app))
150
151 var n int
152 if err := app.db.Writer().QueryRow("SELECT count(*) FROM people").Scan(&n); err != nil {
153 t.Fatal(err)
154 }
155 if n != 1 {
156 t.Fatalf("%d person rows for one address, want 1", n)
157 }
158}
159
160// A GET does not redeem. A mail scanner's prefetch must leave the link
161// usable, and must not sign anybody in — the second half is login CSRF,
162// where any page the victim loads could embed the link as an image and
163// silently put them in the attacker's session.
164func TestGetDoesNotRedeemTheLink(t *testing.T) {
165 app := newTestApp(t)
166 signIn(t, app, "person@example.invalid")
167 token := capturedLink(t, app)
168
169 req := httptest.NewRequest(http.MethodGet, linkPath+"?token="+url.QueryEscape(token), nil)
170 req.Host = credentialHost
171 w := httptest.NewRecorder()
172 app.Router().ServeHTTP(w, req)
173
174 if w.Code != http.StatusOK {
175 t.Fatalf("GET %s = %d, want 200", linkPath, w.Code)
176 }
177 if c := sessionCookie(w); c != nil {
178 t.Fatal("a GET minted a session; that is login CSRF")
179 }
180 var n int
181 if err := app.db.Writer().QueryRow("SELECT count(*) FROM sessions").Scan(&n); err != nil {
182 t.Fatal(err)
183 }
184 if n != 0 {
185 t.Fatalf("a GET created %d sessions", n)
186 }
187
188 // And the link still works afterwards, which is the prefetch half.
189 if rec := redeem(t, app, token); rec.Code != http.StatusSeeOther {
190 t.Fatalf("the link was burned by a GET: redeem = %d", rec.Code)
191 }
192}
193
194// The confirmation page must not look the token up. If it did, a valid
195// token would render differently from an invalid one, and a prefetch
196// could probe validity — the oracle the POST-redeems design closes.
197func TestVerifyPageDoesNotDistinguishTokens(t *testing.T) {
198 app := newTestApp(t)
199 signIn(t, app, "person@example.invalid")
200 real := capturedLink(t, app)
201
202 get := func(token string) *httptest.ResponseRecorder {
203 req := httptest.NewRequest(http.MethodGet, linkPath+"?token="+url.QueryEscape(token), nil)
204 req.Host = credentialHost
205 w := httptest.NewRecorder()
206 app.Router().ServeHTTP(w, req)
207 return w
208 }
209
210 // A fake token of exactly the same length and alphabet as the real
211 // one. Length matters: each response is normalised by replacing its
212 // OWN token with a placeholder, so if the handler echoed a valid
213 // token twice — or padded it, or truncated it — the two normalised
214 // bodies would differ in length and be caught. The first version of
215 // this test replaced each token with the empty string, which erased
216 // any oracle that was a function of the token and let exactly that
217 // mutant through.
218 fake := strings.Repeat("z", len(real))
219 if fake == real {
220 t.Fatal("the fake token collided with the real one")
221 }
222 // A stated limit: because the fake is the same length by
223 // construction, a response that differed only on token LENGTH would
224 // be invisible here. That is deliberate and not a hole — length
225 // separates well-formed from malformed, which a scanner already
226 // knows from the URL it was handed, and not live from dead, which is
227 // the only thing worth hiding.
228
229 good, bad := get(real), get(fake)
230
231 if good.Code != bad.Code {
232 t.Errorf("status differs: %d vs %d", good.Code, bad.Code)
233 }
234
235 // Headers too. An oracle in a header — a Content-Length, a cache
236 // directive, anything set only on one branch — is just as readable
237 // to a prefetching scanner as one in the body, and comparing only
238 // bodies missed it.
239 if !maps.EqualFunc(good.Header(), bad.Header(), slices.Equal) {
240 t.Errorf("headers differ for a valid token:\n valid: %v\n invalid: %v",
241 good.Header(), bad.Header())
242 }
243
244 // A further stated limit, because the handler's comment claims more
245 // than any response comparison can check. "It touches no state" is
246 // asserted separately, by TestVerifyPageWritesNothing. TIMING is
247 // not asserted at all: a handler that slept only for a live token
248 // would pass everything here, and a prefetching scanner could read
249 // it. Closing that needs the handler to stay branch-free on the
250 // token, which is what it is — and is worth re-reading rather than
251 // re-testing whenever it changes.
252 placeholder := strings.Repeat("#", len(real))
253 g := strings.ReplaceAll(good.Body.String(), real, placeholder)
254 b := strings.ReplaceAll(bad.Body.String(), fake, placeholder)
255 if g != b {
256 t.Errorf("the confirmation page renders differently for a valid token "+
257 "(%d bytes vs %d after normalising the token)", len(g), len(b))
258 }
259}
260
261// Unknown, expired and already-used must be one answer, byte for byte.
262func TestFailedRedemptionsAreIndistinguishable(t *testing.T) {
263 app := newTestApp(t)
264 signIn(t, app, "person@example.invalid")
265 used := capturedLink(t, app)
266 if rec := redeem(t, app, used); rec.Code != http.StatusSeeOther {
267 t.Fatalf("setup redemption failed: %d", rec.Code)
268 }
269
270 unknown := redeem(t, app, "no-such-token")
271 again := redeem(t, app, used)
272 empty := redeem(t, app, "")
273
274 for _, tc := range []struct {
275 name string
276 rec *httptest.ResponseRecorder
277 }{{"already used", again}, {"empty", empty}} {
278 if tc.rec.Code != unknown.Code {
279 t.Errorf("%s: status %d, unknown token gave %d", tc.name, tc.rec.Code, unknown.Code)
280 }
281 if tc.rec.Body.String() != unknown.Body.String() {
282 t.Errorf("%s: body differs from an unknown token's", tc.name)
283 }
284 }
285 if unknown.Code == http.StatusSeeOther {
286 t.Fatal("an unknown token was accepted")
287 }
288}
289
290// Two clicks at the same moment mint one session, not two.
291func TestConcurrentRedemptionMintsOneSession(t *testing.T) {
292 app := newTestApp(t)
293 signIn(t, app, "person@example.invalid")
294 token := capturedLink(t, app)
295
296 const racers = 12
297 var (
298 wg sync.WaitGroup
299 mu sync.Mutex
300 winners int
301 )
302 start := make(chan struct{})
303 for range racers {
304 wg.Add(1)
305 go func() {
306 defer wg.Done()
307 <-start
308 rec := redeem(t, app, token)
309 mu.Lock()
310 defer mu.Unlock()
311 if rec.Code == http.StatusSeeOther {
312 winners++
313 }
314 }()
315 }
316 close(start)
317 wg.Wait()
318
319 if winners != 1 {
320 t.Errorf("%d of %d redemptions succeeded, want exactly 1", winners, racers)
321 }
322 var n int
323 if err := app.db.Writer().QueryRow("SELECT count(*) FROM sessions").Scan(&n); err != nil {
324 t.Fatal(err)
325 }
326 if n != 1 {
327 t.Errorf("%d sessions minted from one link, want 1", n)
328 }
329}
330
331// csrf.Protect is mounted router-wide, so the mail-sending endpoint is
332// covered too — it makes outbound DNS and HTTPS calls decided by the
333// submitted address, which is a blind-SSRF surface by design.
334func TestSigninPostRequiresOriginEvidence(t *testing.T) {
335 app := newTestApp(t)
336 for _, path := range []string{"/signin", linkPath} {
337 form := url.Values{"address": {"person@example.invalid"}}
338 req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
339 req.Host = credentialHost
340 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
341 w := httptest.NewRecorder()
342 app.Router().ServeHTTP(w, req)
343 if w.Code < 400 {
344 t.Errorf("POST %s with no origin evidence = %d, want a refusal", path, w.Code)
345 }
346 }
347 if _, sent := app.mailer.last(); sent {
348 t.Error("a CSRF-refused POST still sent mail")
349 }
350}
351
352// The per-IP limiter key is the difference between a rate limit and a
353// suite-wide outage. Over a unix socket RemoteAddr is "@", and signin
354// skips the per-IP budget only for the empty string — so "@" would make
355// every request in the world share one budget of 20 per 15 minutes.
356func TestClientIPNeverCollapsesToOneBucket(t *testing.T) {
357 req := func(remote, xff string) *http.Request {
358 r := httptest.NewRequest(http.MethodPost, "/signin", nil)
359 r.Host = credentialHost
360 r.RemoteAddr = remote
361 if xff != "" {
362 r.Header.Set("X-Forwarded-For", xff)
363 }
364 return r
365 }
366
367 if got := clientIP(req("@", "")); got != "" {
368 t.Errorf("clientIP over a unix socket = %q, want \"\" — %q would be one budget for everybody", got, got)
369 }
370 if got := clientIP(req("@", "203.0.113.7")); got != "203.0.113.7" {
371 t.Errorf("clientIP with X-Forwarded-For = %q, want 203.0.113.7", got)
372 }
373 // The last element is the edge's own view; earlier ones are
374 // whatever the client claimed.
375 if got := clientIP(req("@", "10.0.0.1, 203.0.113.7")); got != "203.0.113.7" {
376 t.Errorf("clientIP took %q rather than the last element", got)
377 }
378 if got := clientIP(req("192.0.2.4:5555", "")); got != "192.0.2.4" {
379 t.Errorf("clientIP from RemoteAddr = %q, want 192.0.2.4", got)
380 }
381 // Two different clients must not share a key.
382 if clientIP(req("@", "203.0.113.7")) == clientIP(req("@", "203.0.113.8")) {
383 t.Error("two clients share one limiter key")
384 }
385}
386
387// The keymail callback mints a session on a GET, which is only safe
388// because the return is bound to the browser that began the flow. This
389// asserts the binding rather than trusting the comment that claims it.
390//
391// An attacker holding a code and a state but no pending cookie must get
392// nothing. Three things stop them, and this exercises the first: the
393// blob is absent so openPending refuses before any network call; the
394// blob is HMAC'd under Flow.Key so it cannot be forged; and state must
395// match the one sealed inside it.
396func TestCallbackWithNoPendingCookieMintsNothing(t *testing.T) {
397 app := newTestApp(t)
398
399 for _, q := range []string{
400 "?code=stolen&state=guessed",
401 "?code=&state=",
402 "?state=guessed",
403 "",
404 } {
405 req := httptest.NewRequest(http.MethodGet, callbackPath+q, nil)
406 req.Host = credentialHost
407 w := httptest.NewRecorder()
408 app.Router().ServeHTTP(w, req)
409
410 if c := sessionCookie(w); c != nil {
411 t.Fatalf("callback%s minted a session with no pending cookie", q)
412 }
413 var n int
414 if err := app.db.Writer().QueryRow("SELECT count(*) FROM sessions").Scan(&n); err != nil {
415 t.Fatal(err)
416 }
417 if n != 0 {
418 t.Fatalf("callback%s created %d sessions", q, n)
419 }
420 // And it is not a dead end: a failed round trip goes back to the
421 // form with the override set, so the next attempt takes the
422 // magic link.
423 if loc := w.Header().Get("Location"); loc != "/signin?force=1" {
424 t.Errorf("callback%s went to %q, want /signin?force=1", q, loc)
425 }
426 }
427
428 // No mail either: a callback must not be a way to make the home send
429 // anything to an address the caller named.
430 if _, sent := app.mailer.last(); sent {
431 t.Error("a failed callback sent mail")
432 }
433}
434
435// A forged pending blob must not open. It is HMAC'd under Flow.Key, and
436// that key comes from config precisely because sign-in runs before any
437// session exists.
438func TestForgedPendingBlobIsRefused(t *testing.T) {
439 app := newTestApp(t)
440
441 req := httptest.NewRequest(http.MethodGet, callbackPath+"?code=x&state=s", nil)
442 req.Host = credentialHost
443 req.AddCookie(&http.Cookie{
444 Name: pendingCookie,
445 // Well-formed shape — payload.mac — with a MAC that is not one.
446 Value: "eyJzIjoicyIsInYiOiJ2IiwiaCI6ImV2aWwuaW52YWxpZCIsImEiOiJ2aWN0aW1AZXhhbXBsZS5pbnZhbGlkIiwiZSI6OTk5OTk5OTk5OX0.bm90LWEtdmFsaWQtbWFj",
447 })
448 w := httptest.NewRecorder()
449 app.Router().ServeHTTP(w, req)
450
451 if c := sessionCookie(w); c != nil {
452 t.Fatal("a forged pending blob minted a session")
453 }
454 var n int
455 if err := app.db.Writer().QueryRow("SELECT count(*) FROM sessions").Scan(&n); err != nil {
456 t.Fatal(err)
457 }
458 if n != 0 {
459 t.Fatalf("a forged pending blob created %d sessions", n)
460 }
461}
462
463// The rate limiter, driven end to end. TestClientIPNeverCollapsesToOneBucket
464// checks what clientIP returns; nothing checked that the budget it feeds
465// actually bites, so handleSigninBegin's 429 branch was dead code as far
466// as the suite was concerned.
467func TestSigninIsRateLimitedPerIPNotGlobally(t *testing.T) {
468 app := newTestApp(t)
469
470 post := func(ip, addr string) int {
471 form := url.Values{"address": {addr}}
472 req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode()))
473 req.Host = credentialHost
474 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
475 req.Header.Set("Origin", Origin)
476 req.Header.Set("X-Forwarded-For", ip)
477 w := httptest.NewRecorder()
478 app.Router().ServeHTTP(w, req)
479 return w.Code
480 }
481
482 // One IP, distinct addresses so the per-address budget (5) is not
483 // what fires. The per-IP budget is 20 per 15 minutes.
484 var limited bool
485 for i := range 25 {
486 if post("198.51.100.7", fmt.Sprintf("p%d@example.invalid", i)) == http.StatusTooManyRequests {
487 limited = true
488 break
489 }
490 }
491 if !limited {
492 t.Error("25 sign-in attempts from one IP were never rate limited")
493 }
494
495 // A different IP must have its own budget. If the key had collapsed
496 // — which is what "@" over a unix socket would have done — this
497 // request would already be refused.
498 if code := post("203.0.113.9", "someone-else@example.invalid"); code == http.StatusTooManyRequests {
499 t.Error("a second IP was refused on the first attempt: the budget is global, not per-IP")
500 }
501}
502
503// And the per-address budget exists too, independently of the per-IP
504// one: the same address from many IPs is still bounded.
505func TestSigninIsRateLimitedPerAddress(t *testing.T) {
506 app := newTestApp(t)
507
508 var limited bool
509 for i := range 12 {
510 form := url.Values{"address": {"target@example.invalid"}}
511 req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode()))
512 req.Host = credentialHost
513 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
514 req.Header.Set("Origin", Origin)
515 req.Header.Set("X-Forwarded-For", fmt.Sprintf("198.51.100.%d", i+1))
516 w := httptest.NewRecorder()
517 app.Router().ServeHTTP(w, req)
518 if w.Code == http.StatusTooManyRequests {
519 limited = true
520 break
521 }
522 }
523 if !limited {
524 t.Error("12 attempts at one address from 12 IPs were never rate limited: " +
525 "a mail bomb only needs a botnet")
526 }
527}
528
529// The confirmation page's comment says it TOUCHES NO STATE. The
530// indistinguishability test cannot check that — a GET that quietly
531// extended a live link's expiry would render identically and pass — so
532// this checks the database directly.
533func TestVerifyPageWritesNothing(t *testing.T) {
534 app := newTestApp(t)
535 signIn(t, app, "person@example.invalid")
536 token := capturedLink(t, app)
537
538 before := dumpDatabase(t, app)
539 for _, tok := range []string{token, "not-a-real-token", ""} {
540 req := httptest.NewRequest(http.MethodGet, linkPath+"?token="+url.QueryEscape(tok), nil)
541 req.Host = credentialHost
542 app.Router().ServeHTTP(httptest.NewRecorder(), req)
543 }
544 if after := dumpDatabase(t, app); after != before {
545 t.Errorf("GET %s changed stored state:\nbefore:\n%s\nafter:\n%s", linkPath, before, after)
546 }
547}
548