MFA fix, added IP blacklist, update webmail client
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mailgoserver/internal/smime"
|
||||
)
|
||||
|
||||
// parseRawAsEntity is a test-only helper that reads a stored raw RFC822 message's
|
||||
// Content-Type header and body into a smime.Entity — enough to feed into
|
||||
// smime.VerifySigned/Decrypt without needing to export parseEntity from the smime
|
||||
// package just for tests.
|
||||
func parseRawAsEntity(t *testing.T, raw []byte) smime.Entity {
|
||||
t.Helper()
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("parse raw message: %v", err)
|
||||
}
|
||||
body, err := io.ReadAll(msg.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
entity := smime.Entity{Headers: []string{"Content-Type: " + msg.Header.Get("Content-Type")}, Body: body}
|
||||
if cte := msg.Header.Get("Content-Transfer-Encoding"); cte != "" {
|
||||
entity.Headers = append(entity.Headers, "Content-Transfer-Encoding: "+cte)
|
||||
}
|
||||
return entity
|
||||
}
|
||||
|
||||
// genIdentity generates and stores an S/MIME identity for a mailbox directly
|
||||
// (bypassing HTTP — the identity-management HTTP flow itself is covered separately
|
||||
// in webmail_smime_test.go), returning its ID.
|
||||
func genIdentity(t *testing.T, app *App, mailboxID int64, email string) int64 {
|
||||
t.Helper()
|
||||
certPEM, keyPEM, err := smime.GenerateSelfSigned(email, smime.DefaultValidity)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.storeIdentity(mailboxID, certPEM, keyPEM); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identities, err := app.DB.ListSMIMEIdentities(mailboxID)
|
||||
if err != nil || len(identities) == 0 {
|
||||
t.Fatalf("expected the identity to be stored, err=%v", err)
|
||||
}
|
||||
return identities[0].ID // most recently created
|
||||
}
|
||||
|
||||
// TestWebmailComposeSignSend confirms checking "Sign" produces a message the
|
||||
// recipient (or anyone) can verify as genuinely from the sender — no passphrase
|
||||
// involved, since S/MIME keys are stored plain in this codebase.
|
||||
func TestWebmailComposeSignSend(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, _ := app.DB.ListDomains()
|
||||
domainID := domains[0].ID
|
||||
|
||||
senderID := createTestMailboxWithPassword(t, app, "signer@example.com", domainID, "signer-password-1!")
|
||||
recipientID := createTestMailboxWithPassword(t, app, "signee@example.com", domainID, "signee-password-1!")
|
||||
genIdentity(t, app, senderID, "signer@example.com")
|
||||
cookie := webmailLoginSession(t, app, senderID)
|
||||
|
||||
form := url.Values{
|
||||
"to": {"signee@example.com"}, "subject": {"Signed"}, "body_html": {"trust me"},
|
||||
"smime_sign": {"1"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("compose send: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
|
||||
if err != nil || len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d (err=%v)", len(msgs), err)
|
||||
}
|
||||
raw, err := app.Mailstore.FetchMessage(recipientID, msgs[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entity := parseRawAsEntity(t, raw)
|
||||
inner, signer, err := smime.VerifySigned(entity)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifySigned: %v", err)
|
||||
}
|
||||
if signer.EmailAddresses[0] != "signer@example.com" {
|
||||
t.Fatalf("unexpected signer: %v", signer.EmailAddresses)
|
||||
}
|
||||
if !bytes.Contains(inner.Body, []byte("trust me")) {
|
||||
t.Fatalf("expected the body preserved inside the signed entity, got %q", inner.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebmailComposeSignedMessageWithAttachmentsShowsBody confirms a real body
|
||||
// message text still renders on read alongside its attachments when the message is
|
||||
// also S/MIME-signed — the exact combination (signed + multiple attachments + a
|
||||
// typed body) that was never actually exercised by any test before this one
|
||||
// (TestWebmailComposeSendMultipleAttachments checked attachments but not signing or
|
||||
// the body text; the sign-only tests never included attachments).
|
||||
func TestWebmailComposeSignedMessageWithAttachmentsShowsBody(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, _ := app.DB.ListDomains()
|
||||
domainID := domains[0].ID
|
||||
|
||||
senderID := createTestMailboxWithPassword(t, app, "signedattach-sender@example.com", domainID, "sender-password-1!")
|
||||
recipientID := createTestMailboxWithPassword(t, app, "signedattach-recip@example.com", domainID, "recip-password-1!")
|
||||
genIdentity(t, app, senderID, "signedattach-sender@example.com")
|
||||
cookie := webmailLoginSession(t, app, senderID)
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
mw.WriteField("to", "signedattach-recip@example.com")
|
||||
mw.WriteField("subject", "test message")
|
||||
mw.WriteField("body_html", "<p>this is my real message text</p>")
|
||||
mw.WriteField("smime_sign", "1")
|
||||
for _, name := range []string{"test.csv", "notes.md", "LICENSE"} {
|
||||
fw, err := mw.CreateFormFile("attachments", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw.Write([]byte("contents of " + name))
|
||||
}
|
||||
mw.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", &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("compose send: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
|
||||
if err != nil || len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d (err=%v)", len(msgs), err)
|
||||
}
|
||||
|
||||
recipientCookie := webmailLoginSession(t, app, recipientID)
|
||||
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(msgs[0].ID, 10), nil)
|
||||
viewReq.AddCookie(recipientCookie)
|
||||
viewRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(viewRec, viewReq)
|
||||
if viewRec.Code != http.StatusOK {
|
||||
t.Fatalf("view: status=%d body=%s", viewRec.Code, viewRec.Body.String())
|
||||
}
|
||||
body := viewRec.Body.String()
|
||||
if !strings.Contains(body, "this is my real message text") {
|
||||
t.Fatalf("expected the message body rendered, got: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "empty message body") {
|
||||
t.Fatal("expected the body NOT reported as empty when real text was sent")
|
||||
}
|
||||
for _, name := range []string{"test.csv", "notes.md", "LICENSE"} {
|
||||
if !strings.Contains(body, name) {
|
||||
t.Errorf("expected attachment %q listed alongside the body", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebmailComposeSignWithoutIdentityFails confirms checking "Sign" with no
|
||||
// identity on file fails cleanly instead of sending unsigned.
|
||||
func TestWebmailComposeSignWithoutIdentityFails(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
domains, _ := app.DB.ListDomains()
|
||||
domainID := domains[0].ID
|
||||
|
||||
senderID := createTestMailboxWithPassword(t, app, "nokey@example.com", domainID, "nokey-password-1!")
|
||||
recipientID := createTestMailboxWithPassword(t, app, "recip4@example.com", domainID, "recip4-password-1!")
|
||||
cookie := webmailLoginSession(t, app, senderID)
|
||||
|
||||
form := url.Values{
|
||||
"to": {"recip4@example.com"}, "subject": {"x"}, "body_html": {"x"},
|
||||
"smime_sign": {"1"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound && rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
|
||||
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
|
||||
if err != nil || len(msgs) != 0 {
|
||||
t.Fatalf("expected no message delivered, got %d (err=%v)", len(msgs), err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user