package main import ( "bytes" "mime/multipart" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) func testForge(t *testing.T) *Forge { t.Helper() store, err := openStore(t.TempDir()) if err != nil { t.Fatal(err) } f, err := newForge(store) if err != nil { t.Fatal(err) } // Only close removes the runtime directory newForge makes. t.Cleanup(f.cgit.close) return f } func addUser(t *testing.T, f *Forge, name, password string) { t.Helper() hash := hashPassword(password) err := f.store.UpdateUsers("users: add "+name, f.store.forgeAuthor(), func(m map[string]User) error { m[name] = User{Origin: originLocal, Notify: true, Password: hash} return nil }) if err != nil { t.Fatal(err) } } func post(h http.Handler, path string, form url.Values, cookies []*http.Cookie) *httptest.ResponseRecorder { r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") for _, c := range cookies { r.AddCookie(c) } w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } // withCookies is a request carrying a session, for asking the forge what the // form token bound to it is. func withCookies(path string, cookies []*http.Cookie) *http.Request { r := httptest.NewRequest("GET", path, nil) for _, c := range cookies { r.AddCookie(c) } return r } func get(h http.Handler, path string, cookies []*http.Cookie) *httptest.ResponseRecorder { r := httptest.NewRequest("GET", path, nil) for _, c := range cookies { r.AddCookie(c) } w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } func TestPasswordHash(t *testing.T) { hash := hashPassword("correct horse battery staple") if !strings.HasPrefix(hash, "$argon2id$v=19$m=") { t.Fatalf("not a PHC string: %q", hash) } if !passwordOK(hash, "correct horse battery staple") { t.Error("the right password was rejected") } if passwordOK(hash, "correct horse battery stapl") { t.Error("a wrong password was accepted") } for _, bad := range []string{"", "hunter2", "$argon2id$v=19$m=1,t=1,p=1$..$..", "$argon2i$v=19$m=1,t=1,p=1$AAAA$AAAA"} { if passwordOK(bad, "hunter2") { t.Errorf("password accepted against hash %q", bad) } } } func TestSessionEpochRevokes(t *testing.T) { f := testForge(t) addUser(t, f, "alice", "hunter2hunter2") w := httptest.NewRecorder() alice, _ := f.store.User("alice") f.setSession(w, "alice", alice.Epoch) cookies := w.Result().Cookies() if len(cookies) != 1 { t.Fatalf("expected one cookie, got %d", len(cookies)) } c := cookies[0] if !c.HttpOnly || c.SameSite != http.SameSiteLaxMode || c.Path != "/" { t.Errorf("session cookie is not HttpOnly, Lax, and rooted: %+v", c) } r := httptest.NewRequest("GET", "/", nil) r.AddCookie(c) if name, _, ok := f.user(r); !ok || name != "alice" { t.Fatal("a fresh session was not accepted") } tampered := httptest.NewRequest("GET", "/", nil) tampered.AddCookie(&http.Cookie{Name: sessionCookie, Value: c.Value[:len(c.Value)-1] + "x"}) if _, _, ok := f.user(tampered); ok { t.Error("a tampered session was accepted") } err := f.store.UpdateUsers("users: revoke", f.store.forgeAuthor(), func(m map[string]User) error { u := m["alice"] u.Epoch++ m["alice"] = u return nil }) if err != nil { t.Fatal(err) } if _, _, ok := f.user(r); ok { t.Error("a session survived an epoch bump") } } // Signing out everywhere has to take the browser asking for it as well as the // others, or the session somebody wants gone most is the one that survives. func TestSignOutEverywhere(t *testing.T) { f := testForge(t) addUser(t, f, "alice", "hunter2hunter2") h := f.routes() sessions := [][]*http.Cookie{signIn(t, f, h, "alice"), signIn(t, f, h, "alice")} for i, s := range sessions { if code := get(h, "/settings", s).Code; code != http.StatusOK { t.Fatalf("session %d was not usable to begin with: status %d", i, code) } } form := url.Values{"csrf": {f.csrf(withCookies("/", sessions[0]))}} if w := post(h, "/settings/sessions", form, sessions[0]); w.Code != http.StatusSeeOther { t.Fatalf("signing out everywhere: status %d", w.Code) } for i, s := range sessions { if code := get(h, "/settings", s).Code; code != http.StatusSeeOther { t.Errorf("session %d survived: status %d", i, code) } } } func TestLoginLogout(t *testing.T) { f := testForge(t) addUser(t, f, "alice", "hunter2hunter2") h := f.routes() if w := post(h, "/login", url.Values{"name": {"alice"}, "password": {"nope"}}, nil); w.Code != http.StatusUnauthorized { t.Fatalf("wrong password: status %d", w.Code) } // The account name is lowercase by construction, so the shift key is forgiven. w := post(h, "/login", url.Values{"name": {"Alice"}, "password": {"hunter2hunter2"}}, nil) if w.Code != http.StatusSeeOther { t.Fatalf("right password: status %d", w.Code) } session := w.Result().Cookies() if body := get(h, "/", session).Body.String(); !strings.Contains(body, `alice`) { t.Errorf("the bar does not show the session: %s", body) } if w := get(h, "/settings", nil); w.Code != http.StatusSeeOther { t.Errorf("settings without a session: status %d", w.Code) } if w := post(h, "/logout", nil, session); w.Code != http.StatusForbidden { t.Error("a signed in post without the form token was accepted") } r := httptest.NewRequest("GET", "/", nil) for _, c := range session { r.AddCookie(c) } token := url.Values{"csrf": {f.csrf(r)}} if w := post(h, "/logout", token, session); w.Code != http.StatusSeeOther { t.Fatalf("logout: status %d", w.Code) } } func TestPasswordChange(t *testing.T) { f := testForge(t) addUser(t, f, "alice", "hunter2hunter2") h := f.routes() session := signIn(t, f, h, "alice") r := httptest.NewRequest("GET", "/settings", nil) for _, c := range session { r.AddCookie(c) } form := url.Values{"csrf": {f.csrf(r)}, "current": {"hunter2hunter2"}, "new": {"opensesame1"}, "again": {"opensesame1"}} w := post(h, "/settings/password", form, session) if w.Code != http.StatusOK { t.Fatalf("password change: status %d, %s", w.Code, w.Body.String()) } // The bump killed the cookie the request carried, so the response has to // both reissue it and still render as signed in. if len(w.Result().Cookies()) != 1 { t.Error("the password change did not reissue the session cookie") } // The header only names the account when there is a session, so this is // how the page says the reissued cookie took. if !strings.Contains(w.Body.String(), `alice`) { t.Error("the page after a password change looks signed out") } if get(h, "/settings", session).Code != http.StatusSeeOther { t.Error("the old session cookie still works") } if get(h, "/settings", w.Result().Cookies()).Code != http.StatusOK { t.Error("the reissued session cookie does not work") } if alice, _ := f.store.User("alice"); !passwordOK(alice.Password, "opensesame1") { t.Error("the new password was not stored") } } func TestCrossSitePostRefused(t *testing.T) { f := testForge(t) h := f.routes() r := httptest.NewRequest("POST", "/login", strings.NewReader("name=alice&password=x")) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.Header.Set("Origin", "https://evil.example") w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusForbidden { t.Errorf("cross-site post: status %d", w.Code) } } // A budget spent by successful sign ins would refuse people doing nothing // wrong. func TestLoginLimitCountsFailuresOnly(t *testing.T) { f := testForge(t) addUser(t, f, "alice", "hunter2hunter2") h := f.routes() key := "login:" + f.peer(httptest.NewRequest("POST", "/login", nil)) for range 3 { if code := post(h, "/login", url.Values{"name": {"alice"}, "password": {"hunter2hunter2"}}, nil).Code; code != http.StatusSeeOther { t.Fatalf("a correct password was refused: status %d", code) } } if f.loginLimit.over(key, 0) { t.Error("signing in correctly spent the budget for guessing") } for range 3 { post(h, "/login", url.Values{"name": {"alice"}, "password": {"nope"}}, nil) } if !f.loginLimit.over(key, 2) { t.Error("failures were not counted") } if f.loginLimit.over(key, 3) { t.Error("more was counted than was tried") } } // Behind something that terminates the connection, every request arrives from // the same address and every budget becomes one bucket. Naming a header is the // way out, and it is a trust decision: an unnamed one is never read, and a // value that is not a single address is never counted. func TestClientAddressFromAHeader(t *testing.T) { f := testForge(t) r := httptest.NewRequest("POST", "/login", nil) r.RemoteAddr = "127.0.0.1:5555" r.Header.Set("CF-Connecting-IP", "203.0.113.9") if got := f.peer(r); got != "127.0.0.1" { t.Errorf("a header nobody named was read: %q", got) } f.cfg.ClientIPHeader = "CF-Connecting-IP" if got := f.peer(r); got != "203.0.113.9" { t.Errorf("the named header was not read: %q", got) } // Anything that is not one address is nobody's address. for _, junk := range []string{"", " ", "not-an-ip", "203.0.113.9, 70.41.3.18", "203.0.113.9:443"} { r.Header.Set("CF-Connecting-IP", junk) if got := f.peer(r); got != "127.0.0.1" { t.Errorf("%q was taken as an address: %q", junk, got) } } // And the budgets follow it. Filled directly, to save the password checks. for range loginsPerHour + 1 { f.loginLimit.charge("login:203.0.113.9") } h := f.routes() attempt := func(ip string) int { body := url.Values{"name": {"alice"}, "password": {"wrong"}}.Encode() req := httptest.NewRequest("POST", "/login", strings.NewReader(body)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("CF-Connecting-IP", ip) w := httptest.NewRecorder() h.ServeHTTP(w, req) return w.Code } if code := attempt("203.0.113.9"); code != http.StatusTooManyRequests { t.Errorf("the client that spent the budget was not refused: %d", code) } if code := attempt("198.51.100.4"); code != http.StatusUnauthorized { t.Errorf("another client behind the same proxy shared its budget: %d", code) } // A name that could never match is refused at startup. cfg := defaultConfig() cfg.ClientIPHeader = "CF-Connecting-IP:" if err := cfg.validate(); err == nil { t.Error("a header name with a colon in it was accepted") } cfg.ClientIPHeader = "CF-Connecting-IP" if err := cfg.validate(); err != nil { t.Errorf("a usable header name was refused: %v", err) } } // A budget spent only by failures is read by over, and a request over refuses // never reaches charge. Rolling the window in charge alone would lock an // address out for ever. func TestLoginLimitLetsGoAfterAnHour(t *testing.T) { f := testForge(t) addUser(t, f, "alice", "hunter2hunter2") h := f.routes() key := "login:" + f.peer(httptest.NewRequest("POST", "/login", nil)) for range loginsPerHour + 1 { f.loginLimit.charge(key) } right := url.Values{"name": {"alice"}, "password": {"hunter2hunter2"}} if code := post(h, "/login", right, nil).Code; code != http.StatusTooManyRequests { t.Fatalf("the budget did not run out: status %d", code) } f.loginLimit.mu.Lock() f.loginLimit.opened = time.Now().Add(-90 * time.Minute) f.loginLimit.mu.Unlock() if code := post(h, "/login", right, nil).Code; code != http.StatusSeeOther { t.Errorf("still refused an hour after the window ran out: status %d", code) } } // Without a cap on the body itself, an unauthenticated POST to any route fills // the disk. Asserted on the route that needs no account at all. func TestFormBodyIsCapped(t *testing.T) { f := testForge(t) h := f.routes() var body bytes.Buffer mw := multipart.NewWriter(&body) part, err := mw.CreateFormFile("logo_file", "big.bin") if err != nil { t.Fatal(err) } if _, err := part.Write(make([]byte, maxUpload+1<<20)); err != nil { t.Fatal(err) } mw.Close() r := httptest.NewRequest("POST", "/login", &body) r.Header.Set("Content-Type", mw.FormDataContentType()) w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusBadRequest { t.Errorf("an oversized multipart body was accepted: status %d", w.Code) } long := url.Values{"name": {strings.Repeat("x", maxForm+1)}, "password": {"x"}} if code := post(h, "/login", long, nil).Code; code != http.StatusBadRequest { t.Errorf("an oversized form body was accepted: status %d", code) } // And the cap is generous enough for what the forms are for: a wiki page at // the size limit, in text where every byte percent encodes to six. addUser(t, f, "alice", "hunter2hunter2") addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers}) session := signIn(t, f, h, "alice") page := url.Values{ "csrf": {f.csrf(withCookies("/", session))}, "base": {""}, "action": {"save"}, "body": {strings.Repeat("é", maxBody/2)}, } if code := post(h, "/notes/wiki/", page, session).Code; code != http.StatusSeeOther { t.Errorf("a wiki page at the size limit was refused: status %d", code) } }