Files
mailgoserver/internal/webui/webmail_smime_test.go
T

252 lines
9.3 KiB
Go

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 }