252 lines
8.8 KiB
Go
252 lines
8.8 KiB
Go
package webui
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"mailgoserver/internal/db"
|
|
)
|
|
|
|
const carddavTestAppPassword = "carddav-test-app-password-1!"
|
|
|
|
// createTestMailboxWithAppPassword creates a mailbox plus an active app password for
|
|
// it — CardDAV/CalDAV auth (DAVBasicAuth) goes through db.VerifyMailboxAppPassword, the
|
|
// same credential IMAP/SMTP use, never the portal session password. Also opts both the
|
|
// domain and the mailbox into both protocols (both master-off by default — see
|
|
// schema.go's caldav_enabled/carddav_enabled comments) so every existing DAV test
|
|
// keeps exercising the actual PUT/GET/PROPFIND behavior rather than the separate
|
|
// enablement gate, which has its own dedicated tests.
|
|
func createTestMailboxWithAppPassword(t *testing.T, app *App, email string, domainID int64) int64 {
|
|
t.Helper()
|
|
mailboxID := createTestMailboxWithPassword(t, app, email, domainID, "portal-password-unused-1!")
|
|
hash, err := db.HashPassword(carddavTestAppPassword)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := app.DB.CreateAppPassword(mailboxID, "carddav test", hash, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.DB.SetDomainCalDAVEnabled(domainID, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.DB.SetDomainCardDAVEnabled(domainID, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.DB.SetMailboxDAVEnabled(mailboxID, true, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return mailboxID
|
|
}
|
|
|
|
func TestCardDAVBasicAuthRequiresAppPassword(t *testing.T) {
|
|
app := newTestApp(t)
|
|
domains, _ := app.DB.ListDomains()
|
|
createTestMailboxWithAppPassword(t, app, "auther@example.com", domains[0].ID)
|
|
srv := httptest.NewServer(app.Mux())
|
|
defer srv.Close()
|
|
|
|
// No credentials at all.
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/carddav/auther@example.com/addressbooks/default/", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("no auth: status=%d, want 401", resp.StatusCode)
|
|
}
|
|
if resp.Header.Get("WWW-Authenticate") == "" {
|
|
t.Error("expected WWW-Authenticate header on 401 so clients know to prompt")
|
|
}
|
|
|
|
// Wrong password — must be logged and counted toward abuse-blacklist scoping, same
|
|
// as a failed SMTP/IMAP auth attempt.
|
|
req, _ = http.NewRequest(http.MethodGet, srv.URL+"/carddav/auther@example.com/addressbooks/default/", nil)
|
|
req.SetBasicAuth("auther@example.com", "wrong-password")
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("wrong password: status=%d, want 401", resp.StatusCode)
|
|
}
|
|
logs, err := app.DB.ListRecentAuthLogs(50)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := false
|
|
for _, l := range logs {
|
|
if l.AuthType == "dav_login" && l.Identifier == "auther@example.com" && !l.Success {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected a failed dav_login auth attempt logged")
|
|
}
|
|
|
|
// Correct credentials succeed — PROPFIND (not a bare GET, which WebDAV only
|
|
// defines for an individual resource, not a collection) on the address book
|
|
// collection itself, matching what a real client does to discover it.
|
|
req, _ = http.NewRequest("PROPFIND", srv.URL+"/carddav/auther@example.com/addressbooks/default/", nil)
|
|
req.Header.Set("Depth", "0")
|
|
req.SetBasicAuth("auther@example.com", carddavTestAppPassword)
|
|
resp, err = http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusMultiStatus {
|
|
t.Fatalf("correct auth PROPFIND: status=%d body=%s, want 207", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
func TestCardDAVPutGetDeleteRoundTrip(t *testing.T) {
|
|
app := newTestApp(t)
|
|
domains, _ := app.DB.ListDomains()
|
|
createTestMailboxWithAppPassword(t, app, "carduser@example.com", domains[0].ID)
|
|
srv := httptest.NewServer(app.Mux())
|
|
defer srv.Close()
|
|
|
|
objURL := srv.URL + "/carddav/carduser@example.com/addressbooks/default/test-uid-1.vcf"
|
|
vcardBody := "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Jane Doe\r\nEMAIL:jane@example.com\r\nTEL:555-1234\r\nORG:Acme\r\nEND:VCARD\r\n"
|
|
|
|
put := func() *http.Response {
|
|
req, _ := http.NewRequest(http.MethodPut, objURL, strings.NewReader(vcardBody))
|
|
req.Header.Set("Content-Type", "text/vcard")
|
|
req.SetBasicAuth("carduser@example.com", carddavTestAppPassword)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
resp := put()
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("PUT: status=%d, want 201", resp.StatusCode)
|
|
}
|
|
|
|
get := func() *http.Response {
|
|
req, _ := http.NewRequest(http.MethodGet, objURL, nil)
|
|
req.SetBasicAuth("carduser@example.com", carddavTestAppPassword)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
resp = get()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET: status=%d body=%s", resp.StatusCode, body)
|
|
}
|
|
if !strings.Contains(string(body), "Jane Doe") || !strings.Contains(string(body), "jane@example.com") {
|
|
t.Fatalf("GET body missing expected fields: %s", body)
|
|
}
|
|
|
|
// The webmail Contacts page reads the same underlying row — a PUT over CardDAV
|
|
// must show up there too.
|
|
contacts, err := app.DB.ListContacts(mustMailboxID(t, app, "carduser@example.com"))
|
|
if err != nil || len(contacts) != 1 || contacts[0].Email != "jane@example.com" || contacts[0].Org != "Acme" {
|
|
t.Fatalf("expected 1 contact from CardDAV PUT visible via ListContacts, got %+v (err=%v)", contacts, err)
|
|
}
|
|
|
|
del := func() *http.Response {
|
|
req, _ := http.NewRequest(http.MethodDelete, objURL, nil)
|
|
req.SetBasicAuth("carduser@example.com", carddavTestAppPassword)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
resp = del()
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("DELETE: status=%d, want 204/200", resp.StatusCode)
|
|
}
|
|
|
|
resp = get()
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("GET after DELETE: status=%d, want 404", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestCardDAVListsWebmailCreatedContact(t *testing.T) {
|
|
app := newTestApp(t)
|
|
domains, _ := app.DB.ListDomains()
|
|
mailboxID := createTestMailboxWithAppPassword(t, app, "seen@example.com", domains[0].ID)
|
|
if _, err := app.DB.CreateContact(mailboxID, "friend@example.com", "Friend Name", "555-0000"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
contacts, err := app.DB.ListContacts(mailboxID)
|
|
if err != nil || len(contacts) != 1 {
|
|
t.Fatal(err)
|
|
}
|
|
uid := contacts[0].UID
|
|
if uid == "" {
|
|
t.Fatal("expected a generated uid on contact created via the webmail Contacts CRUD")
|
|
}
|
|
|
|
srv := httptest.NewServer(app.Mux())
|
|
defer srv.Close()
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/carddav/seen@example.com/addressbooks/default/"+uid+".vcf", nil)
|
|
req.SetBasicAuth("seen@example.com", carddavTestAppPassword)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "Friend Name") {
|
|
t.Fatalf("expected the webmail-created contact visible over CardDAV: status=%d body=%s", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
// TestCardDAVScopedToOwnMailbox confirms one mailbox's app-password credentials can
|
|
// never read another mailbox's contacts, even by guessing a path with the right uid —
|
|
// the path segment is never trusted; the authenticated mailbox from Basic Auth is.
|
|
func TestCardDAVScopedToOwnMailbox(t *testing.T) {
|
|
app := newTestApp(t)
|
|
domains, _ := app.DB.ListDomains()
|
|
victimID := createTestMailboxWithAppPassword(t, app, "victim@example.com", domains[0].ID)
|
|
createTestMailboxWithAppPassword(t, app, "attacker@example.com", domains[0].ID)
|
|
if _, err := app.DB.CreateContact(victimID, "secret@example.com", "Secret Contact", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
contacts, _ := app.DB.ListContacts(victimID)
|
|
uid := contacts[0].UID
|
|
|
|
srv := httptest.NewServer(app.Mux())
|
|
defer srv.Close()
|
|
// Attacker authenticates as themselves but requests a path under the victim's
|
|
// principal — DAVBasicAuth's ctx mailbox (attacker) is what every backend method
|
|
// actually uses, so this must 404, not leak the victim's contact.
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/carddav/victim@example.com/addressbooks/default/"+uid+".vcf", nil)
|
|
req.SetBasicAuth("attacker@example.com", carddavTestAppPassword)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("expected 404 (attacker's own ctx mailbox has no such contact), got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func mustMailboxID(t *testing.T, app *App, email string) int64 {
|
|
t.Helper()
|
|
mbox, err := app.DB.GetMailboxByEmail(email)
|
|
if err != nil || mbox == nil {
|
|
t.Fatalf("mailbox %s not found: %v", email, err)
|
|
}
|
|
return mbox.ID
|
|
}
|