package server import ( "io" "net/http" "net/http/cookiejar" "net/url" "regexp" "strings" "testing" "cc.tiredsysadmin.keeporg/internal/kdbx" ) // browser is a cookie-keeping client, so the web tests behave like a real // browser including the session cookie and the CSRF token in each form. type browser struct { t *testing.T h *harness http *http.Client } func newBrowser(t *testing.T, h *harness) *browser { jar, err := cookiejar.New(nil) if err != nil { t.Fatal(err) } c := *h.http.Client() c.Jar = jar return &browser{t: t, h: h, http: &c} } func (b *browser) get(path string) (int, string) { b.t.Helper() resp, err := b.http.Get(b.h.http.URL + path) if err != nil { b.t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return resp.StatusCode, string(body) } func (b *browser) post(path string, form url.Values) (int, string) { b.t.Helper() resp, err := b.http.PostForm(b.h.http.URL+path, form) if err != nil { b.t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return resp.StatusCode, string(body) } func (b *browser) cookie() string { u, _ := url.Parse(b.h.http.URL) for _, c := range b.http.Jar.Cookies(u) { if c.Name == webCookie { return c.Value } } return "" } var csrfPattern = regexp.MustCompile(`name="csrf" value="([^"]+)"`) func (b *browser) csrf(page string) string { b.t.Helper() m := csrfPattern.FindStringSubmatch(page) if m == nil { b.t.Fatalf("no CSRF token in the page:\n%s", page) } return m[1] } // signIn takes the browser through the sign-in form. func (b *browser) signIn() string { b.t.Helper() status, page := b.post("/web/signin", url.Values{ "username": {"admin"}, "password": {adminPassword}}) if status != http.StatusOK || !strings.Contains(page, "Your vaults") { b.t.Fatalf("sign in: %d\n%s", status, page) } return b.csrf(page) } func TestWebRootOffersSignIn(t *testing.T) { b := newBrowser(t, newHarness(t)) status, page := b.get("/") if status != http.StatusOK || !strings.Contains(page, "Sign in") { t.Fatalf("root = %d\n%s", status, page) } if b.cookie() != "" { t.Fatal("a session cookie was set before signing in") } } func TestWebSignInRejectsWrongPassword(t *testing.T) { b := newBrowser(t, newHarness(t)) status, page := b.post("/web/signin", url.Values{ "username": {"admin"}, "password": {"nope"}}) if status != http.StatusOK || !strings.Contains(page, "wrong password") { t.Fatalf("status %d\n%s", status, page) } if b.cookie() != "" { t.Fatal("a session cookie was set after a failed sign-in") } } func TestWebSessionCookieIsHardened(t *testing.T) { h := newHarness(t) b := newBrowser(t, h) resp, err := b.http.PostForm(h.http.URL+"/web/signin", url.Values{ "username": {"admin"}, "password": {adminPassword}}) if err != nil { t.Fatal(err) } defer resp.Body.Close() // The harness serves over http, so Secure is correctly off; the rest // must be set. var found bool for _, c := range resp.Request.Response.Cookies() { if c.Name != webCookie { continue } found = true if !c.HttpOnly || c.SameSite != http.SameSiteStrictMode || c.Path != "/" { t.Fatalf("cookie = %+v", c) } } if !found { t.Fatal("no session cookie was issued") } } // A server reached over https must mark its cookie Secure, which is also // why the harness above is configured with an http external URL. func TestWebCookieIsSecureBehindHTTPS(t *testing.T) { h := newHarness(t) h.srv.cfg.ExternalURL = "https://keeporg.example.com" resp, err := h.http.Client().PostForm(h.http.URL+"/web/signin", url.Values{ "username": {"admin"}, "password": {adminPassword}}) if err != nil { t.Fatal(err) } defer resp.Body.Close() for _, c := range resp.Request.Response.Cookies() { if c.Name == webCookie && !c.Secure { t.Fatal("the cookie is not marked Secure although the server is https") } } } func TestWebCreatesAVaultThatOpensWithItsPassword(t *testing.T) { h := newHarness(t) b := newBrowser(t, h) csrf := b.signIn() status, page := b.post("/web/vaults", url.Values{ "csrf": {csrf}, "name": {"Web Vault"}, "group": {"everyone"}, "strategy": {"merge"}, "password": {vaultPassword}, "password2": {vaultPassword}, }) if status != http.StatusOK { t.Fatalf("create = %d\n%s", status, page) } if !strings.Contains(page, "Created Web Vault") { t.Fatalf("no confirmation:\n%s", page) } if !strings.Contains(page, "Web Vault.kdbx") { t.Fatal("the new vault is not listed") } // The vault the form created is a real KDBX file with that password. tok, _, _ := h.login("admin", adminPassword, "") var id string _, list := h.call("GET", "/api/v1/databases", tok, nil) for _, row := range list["databases"].([]any) { m := row.(map[string]any) if m["name"] == "Web Vault" { id = m["id"].(string) if m["merge_strategy"] != "merge" { t.Fatalf("strategy = %v", m["merge_strategy"]) } } } if id == "" { t.Fatal("the vault is missing from the API listing") } db, err := kdbx.Open(h.pull(tok, id), vaultPassword) if err != nil { t.Fatalf("the vault created from the web does not open: %v", err) } if db.Name() != "Web Vault" { t.Fatalf("vault name = %q", db.Name()) } // The API must never hand the wrapped key back out. if strings.Contains(strings.ToLower(string(h.stateBytes())), vaultPassword) { t.Fatal("the vault password is in the state file in the clear") } for _, row := range list["databases"].([]any) { if _, leaked := row.(map[string]any)["wrapped_key"]; leaked { t.Fatal("the API returned the wrapped vault key") } } } func TestWebRefusesAFormWithoutItsToken(t *testing.T) { b := newBrowser(t, newHarness(t)) b.signIn() status, _ := b.post("/web/vaults", url.Values{ "name": {"No Token"}, "group": {"everyone"}, "password": {vaultPassword}, "password2": {vaultPassword}, }) if status != http.StatusForbidden { t.Fatalf("a form without a CSRF token returned %d", status) } status, _ = b.post("/web/vaults", url.Values{ "csrf": {"not-the-token"}, "name": {"Wrong Token"}, "group": {"everyone"}, "password": {vaultPassword}, "password2": {vaultPassword}, }) if status != http.StatusForbidden { t.Fatalf("a form with the wrong CSRF token returned %d", status) } } func TestWebRejectsMismatchedAndShortVaultPasswords(t *testing.T) { b := newBrowser(t, newHarness(t)) csrf := b.signIn() _, page := b.post("/web/vaults", url.Values{ "csrf": {csrf}, "name": {"V"}, "group": {"everyone"}, "password": {vaultPassword}, "password2": {"something-else"}, }) if !strings.Contains(page, "do not match") { t.Fatalf("mismatch not reported:\n%s", page) } _, page = b.post("/web/vaults", url.Values{ "csrf": {csrf}, "name": {"V"}, "group": {"everyone"}, "password": {"short"}, "password2": {"short"}, }) if !strings.Contains(page, "at least 12") { t.Fatalf("a short vault password was not reported:\n%s", page) } } func TestWebOnlyOffersGroupsYouAdminister(t *testing.T) { h := newHarness(t) admin, _, _ := h.login("admin", adminPassword, "") // A member who administers nothing sees no create form at all. status, inv := h.call("POST", "/api/v1/invites", admin, map[string]any{"group_id": "everyone", "role": "member", "ttl_hours": 1, "max_uses": 1}) if status != http.StatusCreated { t.Fatalf("invite: %d %v", status, inv) } form := url.Values{"username": {"member"}, "password": {"member-password"}, "password2": {"member-password"}} resp, err := h.http.Client().PostForm(h.http.URL+"/invite/"+inv["token"].(string), form) if err != nil { t.Fatal(err) } resp.Body.Close() b := newBrowser(t, h) status, page := b.post("/web/signin", url.Values{ "username": {"member"}, "password": {"member-password"}}) if status != http.StatusOK { t.Fatalf("member sign in: %d", status) } if strings.Contains(page, "Create vault") { t.Fatal("a member who administers no group was offered the create form") } if !strings.Contains(page, "do not administer a group") { t.Fatalf("no explanation shown:\n%s", page) } } func TestWebSignOutEndsTheSession(t *testing.T) { b := newBrowser(t, newHarness(t)) csrf := b.signIn() if b.cookie() == "" { t.Fatal("no cookie after signing in") } if status, _ := b.post("/web/signout", url.Values{"csrf": {csrf}}); status != http.StatusOK { t.Fatalf("sign out = %d", status) } status, page := b.get("/web/vaults") if status != http.StatusOK || !strings.Contains(page, "Sign in") { t.Fatalf("after sign out: %d\n%s", status, page) } }