From 37eec298d7c990b34dfe630f905c0f2d6144b275 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 6 Aug 2022 23:11:55 +1200 Subject: [PATCH 1/8] 0.1.1 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9366cd9..5312bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ Notable changes to Mailpit will be documented in this file. +## 0.1.1 + +### Bugfix +- Fix env variable for MP_UI_SSL_KEY + + ## 0.1.0 ### Feature From 056bef7d5eb75d51b812f50b02df22fab39a8df8 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 6 Aug 2022 23:35:58 +1200 Subject: [PATCH 2/8] Security: Use strconv.Atoi() for safe string to int conversions --- server/server.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/server/server.go b/server/server.go index 069ce67..65c111e 100644 --- a/server/server.go +++ b/server/server.go @@ -156,16 +156,13 @@ func getStartLimit(req *http.Request) (start int, limit int) { limit = 50 s := req.URL.Query().Get("start") - if n, e := strconv.ParseInt(s, 10, 64); e == nil && n > 0 { - start = int(n) + if n, err := strconv.Atoi(s); err == nil && n > 0 { + start = n } l := req.URL.Query().Get("limit") - if n, e := strconv.ParseInt(l, 10, 64); e == nil && n > 0 { - if n > 500 { - n = 500 - } - limit = int(n) + if n, err := strconv.Atoi(l); err == nil && n > 0 { + limit = n } return start, limit From 11554437854646c5ee26d3056c2b9e2d3f89de33 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 6 Aug 2022 23:53:15 +1200 Subject: [PATCH 3/8] Security: Sanitize mailbox names --- storage/database.go | 41 ++++++++++++++++++++++++++++++++--------- storage/stats.go | 2 ++ storage/utils.go | 8 ++++++++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/storage/database.go b/storage/database.go index f638410..4b83004 100644 --- a/storage/database.go +++ b/storage/database.go @@ -140,40 +140,44 @@ func MailboxExists(name string) bool { } // CreateMailbox will create a collection if it does not exist -func CreateMailbox(name string) error { - if !MailboxExists(name) { - logger.Log().Infof("[db] creating mailbox: %s", name) +func CreateMailbox(mailbox string) error { + mailbox = sanitizeMailboxName(mailbox) - if err := db.CreateCollection(name); err != nil { + if !MailboxExists(mailbox) { + logger.Log().Infof("[db] creating mailbox: %s", mailbox) + + if err := db.CreateCollection(mailbox); err != nil { return err } // create Created index - if err := db.CreateIndex(name, "Created"); err != nil { + if err := db.CreateIndex(mailbox, "Created"); err != nil { return err } // create Read index - if err := db.CreateIndex(name, "Read"); err != nil { + if err := db.CreateIndex(mailbox, "Read"); err != nil { return err } // create separate collection for data - if err := db.CreateCollection(name + "_data"); err != nil { + if err := db.CreateCollection(mailbox + "_data"); err != nil { return err } // create Created index - if err := db.CreateIndex(name+"_data", "Created"); err != nil { + if err := db.CreateIndex(mailbox+"_data", "Created"); err != nil { return err } } - return statsRefresh(name) + return statsRefresh(mailbox) } // Store will store a message in the database and return the unique ID func Store(mailbox string, b []byte) (string, error) { + mailbox = sanitizeMailboxName(mailbox) + r := bytes.NewReader(b) // Parse message body with enmime. env, err := enmime.ReadEnvelope(r) @@ -254,6 +258,8 @@ func Store(mailbox string, b []byte) (string, error) { // as clover's `Skip()` returns a subset of all results which is much slower. // @see https://github.com/ostafen/clover/issues/73 func List(mailbox string, start, limit int) ([]data.Summary, error) { + mailbox = sanitizeMailboxName(mailbox) + var lastDoc *clover.Document count := 0 startAddingAt := start + 1 @@ -314,6 +320,8 @@ func List(mailbox string, start, limit int) ([]data.Summary, error) { // Search returns a summary of items mathing a search. It searched the SearchText field. func Search(mailbox, search string, start, limit int) ([]data.Summary, error) { + mailbox = sanitizeMailboxName(mailbox) + sq := fmt.Sprintf("(?i)%s", cleanString(regexp.QuoteMeta(search))) q, err := db.FindAll(clover.NewQuery(mailbox). Skip(start). @@ -340,11 +348,15 @@ func Search(mailbox, search string, start, limit int) ([]data.Summary, error) { // Count returns the total number of messages in a mailbox func Count(mailbox string) (int, error) { + mailbox = sanitizeMailboxName(mailbox) + return db.Count(clover.NewQuery(mailbox)) } // CountUnread returns the unread number of messages in a mailbox func CountUnread(mailbox string) (int, error) { + mailbox = sanitizeMailboxName(mailbox) + return db.Count( clover.NewQuery(mailbox). Where(clover.Field("Read").IsFalse()), @@ -355,6 +367,8 @@ func CountUnread(mailbox string) (int, error) { // ID must be supplied as this is not stored within the CloverStore but rather the // *clover.Document func GetMessage(mailbox, id string) (*data.Message, error) { + mailbox = sanitizeMailboxName(mailbox) + q, err := db.FindById(mailbox+"_data", id) if err != nil { return nil, err @@ -440,6 +454,8 @@ func GetMessage(mailbox, id string) (*data.Message, error) { // GetAttachmentPart returns an *enmime.Part (attachment or inline) from a message func GetAttachmentPart(mailbox, id, partID string) (*enmime.Part, error) { + mailbox = sanitizeMailboxName(mailbox) + data, err := GetMessageRaw(mailbox, id) if err != nil { return nil, err @@ -475,6 +491,8 @@ func GetAttachmentPart(mailbox, id, partID string) (*enmime.Part, error) { // GetMessageRaw returns an []byte of the full message func GetMessageRaw(mailbox, id string) ([]byte, error) { + mailbox = sanitizeMailboxName(mailbox) + q, err := db.FindById(mailbox+"_data", id) if err != nil { return nil, err @@ -491,6 +509,8 @@ func GetMessageRaw(mailbox, id string) ([]byte, error) { // UnreadMessage will delete all messages from a mailbox func UnreadMessage(mailbox, id string) error { + mailbox = sanitizeMailboxName(mailbox) + updates := make(map[string]interface{}) updates["Read"] = false @@ -501,6 +521,8 @@ func UnreadMessage(mailbox, id string) error { // DeleteOneMessage will delete a single message from a mailbox func DeleteOneMessage(mailbox, id string) error { + mailbox = sanitizeMailboxName(mailbox) + q, err := db.FindById(mailbox, id) if err != nil { return err @@ -519,6 +541,7 @@ func DeleteOneMessage(mailbox, id string) error { // DeleteAllMessages will delete all messages from a mailbox func DeleteAllMessages(mailbox string) error { + mailbox = sanitizeMailboxName(mailbox) totalStart := time.Now() diff --git a/storage/stats.go b/storage/stats.go index 37a8162..f78b81e 100644 --- a/storage/stats.go +++ b/storage/stats.go @@ -15,6 +15,8 @@ var ( // StatsGet returns the total/unread statistics for a mailbox func StatsGet(mailbox string) data.MailboxStats { + mailbox = sanitizeMailboxName(mailbox) + statsLock.Lock() defer statsLock.Unlock() s, ok := mailboxStats[mailbox] diff --git a/storage/utils.go b/storage/utils.go index f2bbb18..511ffdc 100644 --- a/storage/utils.go +++ b/storage/utils.go @@ -92,3 +92,11 @@ func pruneCron() { } } } + +// SanitizeMailboxName returns a clean mailbox name +// allowing only `alphanumeric` characters and `-`` +func sanitizeMailboxName(mailbox string) string { + re := regexp.MustCompile(`[^a-zA-Z0-9\-]`) + + return re.ReplaceAllString(mailbox, "") +} From 788e390e01efdda0513b617eceae64f424c97f11 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sun, 7 Aug 2022 00:09:32 +1200 Subject: [PATCH 4/8] Ignore http.RsponseWriter errors --- server/api.go | 18 +++++++++--------- server/server.go | 2 +- server/websockets/client.go | 2 +- storage/database.go | 4 ++-- storage/utils.go | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/server/api.go b/server/api.go index c00f850..e5f5662 100644 --- a/server/api.go +++ b/server/api.go @@ -29,7 +29,7 @@ func apiListMailboxes(w http.ResponseWriter, _ *http.Request) { bytes, _ := json.Marshal(res) w.Header().Add("Content-Type", "application/json") - w.Write(bytes) + _, _ = w.Write(bytes) } func apiListMailbox(w http.ResponseWriter, r *http.Request) { @@ -62,7 +62,7 @@ func apiListMailbox(w http.ResponseWriter, r *http.Request) { bytes, _ := json.Marshal(res) w.Header().Add("Content-Type", "application/json") - w.Write(bytes) + _, _ = w.Write(bytes) } func apiSearchMailbox(w http.ResponseWriter, r *http.Request) { @@ -102,7 +102,7 @@ func apiSearchMailbox(w http.ResponseWriter, r *http.Request) { bytes, _ := json.Marshal(res) w.Header().Add("Content-Type", "application/json") - w.Write(bytes) + _, _ = w.Write(bytes) } // Open a message @@ -120,7 +120,7 @@ func apiOpenMessage(w http.ResponseWriter, r *http.Request) { bytes, _ := json.Marshal(msg) w.Header().Add("Content-Type", "application/json") - w.Write(bytes) + _, _ = w.Write(bytes) } // Download/view an attachment @@ -143,7 +143,7 @@ func apiDownloadAttachment(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", a.ContentType) w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"") - w.Write(a.Content) + _, _ = w.Write(a.Content) } // View the full email source as plain text @@ -165,7 +165,7 @@ func apiDownloadSource(w http.ResponseWriter, r *http.Request) { if dl == "1" { w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"") } - w.Write(data) + _, _ = w.Write(data) } // Delete all messages in the mailbox @@ -181,7 +181,7 @@ func apiDeleteAll(w http.ResponseWriter, r *http.Request) { } w.Header().Add("Content-Type", "text/plain") - w.Write([]byte("ok")) + _, _ = w.Write([]byte("ok")) } // Delete a single message @@ -198,7 +198,7 @@ func apiDeleteOne(w http.ResponseWriter, r *http.Request) { } w.Header().Add("Content-Type", "text/plain") - w.Write([]byte("ok")) + _, _ = w.Write([]byte("ok")) } // Mark single message as unread @@ -215,7 +215,7 @@ func apiUnreadOne(w http.ResponseWriter, r *http.Request) { } w.Header().Add("Content-Type", "text/plain") - w.Write([]byte("ok")) + _, _ = w.Write([]byte("ok")) } // Websocket to broadcast changes diff --git a/server/server.go b/server/server.go index 65c111e..8c202a9 100644 --- a/server/server.go +++ b/server/server.go @@ -64,7 +64,7 @@ func Listen() { func basicAuthResponse(w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", `Basic realm="Login"`) w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("Unauthorised.\n")) + _, _ = w.Write([]byte("Unauthorised.\n")) } type gzipResponseWriter struct { diff --git a/server/websockets/client.go b/server/websockets/client.go index 2b6f0e1..8499dce 100644 --- a/server/websockets/client.go +++ b/server/websockets/client.go @@ -133,5 +133,5 @@ func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) { func basicAuthResponse(w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", `Basic realm="Login"`) w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("Unauthorised.\n")) + _, _ = w.Write([]byte("Unauthorised.\n")) } diff --git a/storage/database.go b/storage/database.go index 4b83004..b2aa8f1 100644 --- a/storage/database.go +++ b/storage/database.go @@ -224,7 +224,7 @@ func Store(mailbox string, b []byte) (string, error) { if err != nil { // delete the summary because the data insert failed logger.Log().Debugf("[db] error inserting raw message, rolling back") - DeleteOneMessage(mailbox, id) + _ = DeleteOneMessage(mailbox, id) return "", err } @@ -567,7 +567,7 @@ func DeleteAllMessages(mailbox string) error { } // resets stats for mailbox - statsRefresh(mailbox) + _ = statsRefresh(mailbox) elapsed := time.Since(totalStart) logger.Log().Infof("Deleted %d messages from %s in %s", totalMessages, mailbox, elapsed) diff --git a/storage/utils.go b/storage/utils.go index 511ffdc..2cd2c19 100644 --- a/storage/utils.go +++ b/storage/utils.go @@ -84,7 +84,7 @@ func pruneCron() { } elapsed := time.Since(start) logger.Log().Infof("Pruned %d messages from %s in %s", limit, m, elapsed) - statsRefresh(m) + _ = statsRefresh(m) if !strings.HasSuffix(m, "_data") { websockets.Broadcast("prune", nil) } From 544f0175d9968151df8fcd537f6399ea3fcb05fa Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sun, 7 Aug 2022 00:26:18 +1200 Subject: [PATCH 5/8] Security: Don't allow tar files containing a ".." --- updater/targz.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/updater/targz.go b/updater/targz.go index 4819b05..02d30c1 100644 --- a/updater/targz.go +++ b/updater/targz.go @@ -8,6 +8,7 @@ import ( "io" "os" "path/filepath" + "strings" "syscall" ) @@ -184,6 +185,10 @@ func extract(filePath string, directory string) error { } fileInfo := header.FileInfo() + // paths could contain a '..', is used in a file system operations + if strings.Contains(fileInfo.Name(), "..") { + continue + } dir := filepath.Join(directory, filepath.Dir(header.Name)) filename := filepath.Join(dir, fileInfo.Name()) From 642487742c8ba27f1a24c68098bfaa81cff0677a Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sun, 7 Aug 2022 01:04:55 +1200 Subject: [PATCH 6/8] Feature: Optional browser notifications (HTTPS only) --- server/ui-src/App.vue | 85 ++++++++++++++++++++++++++++++++++++++---- server/ui/mailpit.png | Bin 0 -> 3044 bytes 2 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 server/ui/mailpit.png diff --git a/server/ui-src/App.vue b/server/ui-src/App.vue index 139076e..e846b83 100644 --- a/server/ui-src/App.vue +++ b/server/ui-src/App.vue @@ -21,7 +21,9 @@ export default { searching: false, isConnected: false, scrollInPlace: false, - message: false + message: false, + notificationsSupported: false, + notificationsEnabled: false } }, watch: { @@ -47,6 +49,10 @@ export default { this.currentPath = window.location.hash.slice(1); }); + this.notificationsSupported = 'https:' == document.location.protocol + && ("Notification" in window && Notification.permission !== "denied"); + this.notificationsEnabled = this.notificationsSupported && Notification.permission == "granted"; + this.connect(); this.loadMessages(); }, @@ -214,6 +220,7 @@ export default { } self.total++; self.unread++; + self.browserNotify("New mail from: " + response.Data.From.Address, response.Data.Subject); } else if (response.Type == "prune") { // messages have been deleted, reload messages to adjust self.scrollInPlace = true; @@ -252,6 +259,41 @@ export default { let d = new Date(message.Created) return moment(d).fromNow().toString(); }, + + browserNotify: function(title, message) { + if (!("Notification" in window)) { + return; + } + + if (Notification.permission === "granted") { + let b = message.Subject; + let options = { + body: message, + icon: 'mailpit.png' + } + new Notification(title, options); + } + }, + + requestNotifications: function() { + // check if the browser supports notifications + if (!("Notification" in window)) { + alert("This browser does not support desktop notification"); + } + + // we need to ask the user for permission + else if (Notification.permission !== "denied") { + let self = this; + Notification.requestPermission().then(function (permission) { + // If the user accepts, let's create a notification + if (permission === "granted") { + self.browserNotify("Notifications enabled", "You will receive notifications when new mails are received."); + self.notificationsEnabled = true; + } + }); + } + } + } } @@ -315,7 +357,7 @@ export default {
-
-