MFA fix, added IP blacklist, update webmail client
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mailgoserver/internal/smime"
|
||||
)
|
||||
|
||||
// TestWebmailSMIMEGenerateAndDownload confirms a mailbox owner can generate a
|
||||
// self-signed identity (no passphrase — S/MIME keys are stored plain, since S/MIME
|
||||
// is sign-only here), see it reflected on the page, and download the public
|
||||
// certificate.
|
||||
func TestWebmailSMIMEGenerateAndDownload(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, _ := app.DB.ListDomains()
|
||||
mailboxID := createTestMailboxWithPassword(t, app, "smime1@example.com", domains[0].ID, "smime-password-1!")
|
||||
cookie := webmailLoginSession(t, app, mailboxID)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/identity/generate", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("generate: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
identities, err := app.DB.ListSMIMEIdentities(mailboxID)
|
||||
if err != nil || len(identities) != 1 {
|
||||
t.Fatalf("expected 1 identity stored, got %d (err=%v)", len(identities), err)
|
||||
}
|
||||
identity := identities[0]
|
||||
if !strings.Contains(identity.KeyPEM, "PRIVATE KEY") {
|
||||
t.Fatal("expected the stored key to be a usable plain PEM")
|
||||
}
|
||||
cert, err := smime.ParseCertPEM([]byte(identity.CertPEM))
|
||||
if err != nil {
|
||||
t.Fatalf("stored cert doesn't parse: %v", err)
|
||||
}
|
||||
if len(cert.EmailAddresses) != 1 || cert.EmailAddresses[0] != "smime1@example.com" {
|
||||
t.Fatalf("unexpected cert EmailAddresses: %v", cert.EmailAddresses)
|
||||
}
|
||||
|
||||
pageReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/certs", nil)
|
||||
pageReq.AddCookie(cookie)
|
||||
pageRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(pageRec, pageReq)
|
||||
if pageRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected the certs page to render, status=%d body=%s", pageRec.Code, pageRec.Body.String())
|
||||
}
|
||||
|
||||
dlReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/smime/identity/"+strconv.FormatInt(identity.ID, 10)+"/download", nil)
|
||||
dlReq.AddCookie(cookie)
|
||||
dlRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(dlRec, dlReq)
|
||||
if dlRec.Code != http.StatusOK || dlRec.Body.String() != identity.CertPEM {
|
||||
t.Fatalf("expected downloaded cert to match stored cert, status=%d", dlRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebmailSMIMEMultipleIdentities confirms a mailbox can hold more than one
|
||||
// identity at once.
|
||||
func TestWebmailSMIMEMultipleIdentities(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, _ := app.DB.ListDomains()
|
||||
mailboxID := createTestMailboxWithPassword(t, app, "smime-multi@example.com", domains[0].ID, "smime-password-1!")
|
||||
cookie := webmailLoginSession(t, app, mailboxID)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/identity/generate", nil)
|
||||
req.AddCookie(cookie)
|
||||
mux.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
|
||||
identities, err := app.DB.ListSMIMEIdentities(mailboxID)
|
||||
if err != nil || len(identities) != 2 {
|
||||
t.Fatalf("expected 2 identities held simultaneously, got %d (err=%v)", len(identities), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebmailSMIMERemoveIdentity confirms removal actually deletes the DB row.
|
||||
func TestWebmailSMIMERemoveIdentity(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, _ := app.DB.ListDomains()
|
||||
mailboxID := createTestMailboxWithPassword(t, app, "smime2@example.com", domains[0].ID, "smime-password-1!")
|
||||
cookie := webmailLoginSession(t, app, mailboxID)
|
||||
|
||||
genReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/identity/generate", nil)
|
||||
genReq.AddCookie(cookie)
|
||||
mux.ServeHTTP(httptest.NewRecorder(), genReq)
|
||||
identities, _ := app.DB.ListSMIMEIdentities(mailboxID)
|
||||
if len(identities) != 1 {
|
||||
t.Fatalf("expected 1 identity, got %d", len(identities))
|
||||
}
|
||||
|
||||
rmReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/identity/"+strconv.FormatInt(identities[0].ID, 10)+"/remove", nil)
|
||||
rmReq.AddCookie(cookie)
|
||||
rmRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rmRec, rmReq)
|
||||
if rmRec.Code != http.StatusFound {
|
||||
t.Fatalf("remove: status=%d", rmRec.Code)
|
||||
}
|
||||
|
||||
remaining, err := app.DB.ListSMIMEIdentities(mailboxID)
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("expected identity gone, got %d (err=%v)", len(remaining), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebmailSMIMEContactAddAndRemove confirms a contact certificate can be added
|
||||
// (validated as a real cert), listed, and removed again.
|
||||
func TestWebmailSMIMEContactAddAndRemove(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, _ := app.DB.ListDomains()
|
||||
mailboxID := createTestMailboxWithPassword(t, app, "smime3@example.com", domains[0].ID, "smime-password-1!")
|
||||
cookie := webmailLoginSession(t, app, mailboxID)
|
||||
|
||||
contactCertPEM, _, err := smime.GenerateSelfSigned("contact@other.example", smime.DefaultValidity)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
mw.WriteField("email", "contact@other.example")
|
||||
fw, err := mw.CreateFormFile("cert_file", "contact.pem")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw.Write(contactCertPEM)
|
||||
mw.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/contacts/add", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("add contact: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
contacts, err := app.DB.ListSMIMEContacts(mailboxID)
|
||||
if err != nil || len(contacts) != 1 || contacts[0].Email != "contact@other.example" {
|
||||
t.Fatalf("expected 1 contact, got %+v (err=%v)", contacts, err)
|
||||
}
|
||||
|
||||
rmReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/contacts/"+strconv.FormatInt(contacts[0].ID, 10)+"/remove", nil)
|
||||
rmReq.AddCookie(cookie)
|
||||
rmRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rmRec, rmReq)
|
||||
if rmRec.Code != http.StatusFound {
|
||||
t.Fatalf("remove contact: status=%d", rmRec.Code)
|
||||
}
|
||||
remaining, err := app.DB.ListSMIMEContacts(mailboxID)
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("expected no contacts left, got %d (err=%v)", len(remaining), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebmailSMIMEAddContactRejectsGarbage confirms an upload that isn't a valid
|
||||
// certificate is rejected rather than silently stored.
|
||||
func TestWebmailSMIMEAddContactRejectsGarbage(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, _ := app.DB.ListDomains()
|
||||
mailboxID := createTestMailboxWithPassword(t, app, "smime4@example.com", domains[0].ID, "smime-password-1!")
|
||||
cookie := webmailLoginSession(t, app, mailboxID)
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
mw.WriteField("email", "nope@example.com")
|
||||
fw, err := mw.CreateFormFile("cert_file", "notacert.pem")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw.Write([]byte("this is not a certificate"))
|
||||
mw.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/contacts/add", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
|
||||
contacts, err := app.DB.ListSMIMEContacts(mailboxID)
|
||||
if err != nil || len(contacts) != 0 {
|
||||
t.Fatalf("expected the invalid cert rejected, got %d contacts (err=%v)", len(contacts), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebmailSMIMEScopedToOwnMailbox confirms one mailbox owner can't remove
|
||||
// another's contact by guessing its ID, and can't reach another's identity.
|
||||
func TestWebmailSMIMEScopedToOwnMailbox(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, _ := app.DB.ListDomains()
|
||||
victimID := createTestMailboxWithPassword(t, app, "victim3@example.com", domains[0].ID, "victim-password-1!")
|
||||
attackerID := createTestMailboxWithPassword(t, app, "attacker3@example.com", domains[0].ID, "attacker-password-1!")
|
||||
|
||||
if err := app.DB.UpsertSMIMEContact(victimID, "someone@example.com", "irrelevant-pem-for-this-test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contacts, _ := app.DB.ListSMIMEContacts(victimID)
|
||||
|
||||
attackerCookie := webmailLoginSession(t, app, attackerID)
|
||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/contacts/"+strconv.FormatInt(contacts[0].ID, 10)+"/remove", nil)
|
||||
req.AddCookie(attackerCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
|
||||
stillThere, err := app.DB.ListSMIMEContacts(victimID)
|
||||
if err != nil || len(stillThere) != 1 {
|
||||
t.Fatalf("expected the victim's contact untouched, got %d (err=%v)", len(stillThere), err)
|
||||
}
|
||||
|
||||
// Attacker downloading the victim's identity by guessing its ID should 404, not
|
||||
// leak the victim's cert.
|
||||
victimCookie := webmailLoginSession(t, app, victimID)
|
||||
genReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/identity/generate", nil)
|
||||
genReq.AddCookie(victimCookie)
|
||||
mux.ServeHTTP(httptest.NewRecorder(), genReq)
|
||||
victimIdentities, _ := app.DB.ListSMIMEIdentities(victimID)
|
||||
if len(victimIdentities) != 1 {
|
||||
t.Fatalf("expected 1 victim identity, got %d", len(victimIdentities))
|
||||
}
|
||||
|
||||
dlReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/smime/identity/"+strconv.FormatInt(victimIdentities[0].ID, 10)+"/download", nil)
|
||||
dlReq.AddCookie(attackerCookie)
|
||||
dlRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(dlRec, dlReq)
|
||||
if dlRec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for an attacker guessing another mailbox's identity ID, got %d", dlRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func sessionTokenFromCookie(c *http.Cookie) string { return c.Value }
|
||||
Reference in New Issue
Block a user