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)
}
}