diff options
| author | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
| commit | bb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch) | |
| tree | 6d5eaf5dac213074cd832da50f64260fce728fed /auth_test.go | |
| download | git-forge-master.tar.gz | |
A small self-hosted git forge: HTTP push and pull, cgit browsing,
local and OIDC accounts, TOTP, scoped push tokens, per-repo
permissions, issues, a wiki, and outbound mail.
It does not reimplement git. git-http-backend and cgit run as CGI
behind its own listener, which authorises every request before
exec'ing either. One binary, one data directory, one listener: no
database, no JavaScript, and state is JSON in a git repo with a
working tree.
Diffstat (limited to 'auth_test.go')
| -rw-r--r-- | auth_test.go | 395 |
1 files changed, 395 insertions, 0 deletions
diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..35d1818 --- /dev/null +++ b/auth_test.go @@ -0,0 +1,395 @@ +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, `<a href="/settings">alice</a>`) { + 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(), `<a href="/settings">alice</a>`) { + 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) + } +} |