252 lines
9.3 KiB
Go
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 }
|