From 5f75e98861702c662d97f97ff65cf99f63e85ae0 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 19 Feb 2023 10:13:25 -0500 Subject: [PATCH] Parse nested multipart emails, fixes #610 --- docs/releases.md | 2 +- server/smtp_server.go | 37 +++++++++++------- server/smtp_server_test.go | 77 ++++++++++++++++++++++++++++++++------ 3 files changed, 90 insertions(+), 26 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index d9dad6c..d93d07d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,7 +6,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes + maintenance:** -* Support for base64 encoded emails ([#610](https://github.com/binwiederhier/ntfy/issues/610), thanks to [@Robert-litts](https://github.com/Robert-litts)) +* Support for base64-encoded and nested multipart emails ([#610](https://github.com/binwiederhier/ntfy/issues/610), thanks to [@Robert-litts](https://github.com/Robert-litts)) **Additional languages:** diff --git a/server/smtp_server.go b/server/smtp_server.go index aefe314..3e1df55 100644 --- a/server/smtp_server.go +++ b/server/smtp_server.go @@ -22,9 +22,14 @@ var ( errInvalidAddress = errors.New("invalid address") errInvalidTopic = errors.New("invalid topic") errTooManyRecipients = errors.New("too many recipients") + errMultipartNestedTooDeep = errors.New("multipart message nested too deep") errUnsupportedContentType = errors.New("unsupported content type") ) +const ( + maxMultipartDepth = 2 +) + // smtpBackend implements SMTP server methods. type smtpBackend struct { config *Config @@ -121,7 +126,7 @@ func (s *smtpSession) Data(r io.Reader) error { if err != nil { return err } - body, err := readMailBody(msg) + body, err := readMailBody(msg.Body, msg.Header) if err != nil { return err } @@ -203,36 +208,42 @@ func (s *smtpSession) withFailCount(fn func() error) error { return err } -func readMailBody(msg *mail.Message) (string, error) { - if msg.Header.Get("Content-Type") == "" { - return readPlainTextMailBody(msg.Body, msg.Header.Get("Content-Transfer-Encoding")) +func readMailBody(body io.Reader, header mail.Header) (string, error) { + if header.Get("Content-Type") == "" { + return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding")) } - contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) + contentType, params, err := mime.ParseMediaType(header.Get("Content-Type")) if err != nil { return "", err } if strings.ToLower(contentType) == "text/plain" { - return readPlainTextMailBody(msg.Body, msg.Header.Get("Content-Transfer-Encoding")) + return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding")) } else if strings.HasPrefix(strings.ToLower(contentType), "multipart/") { - return readMultipartMailBody(msg, params) + return readMultipartMailBody(body, params, 0) } return "", errUnsupportedContentType } -func readMultipartMailBody(msg *mail.Message, params map[string]string) (string, error) { - mr := multipart.NewReader(msg.Body, params["boundary"]) +func readMultipartMailBody(body io.Reader, params map[string]string, depth int) (string, error) { + if depth >= maxMultipartDepth { + return "", errMultipartNestedTooDeep + } + mr := multipart.NewReader(body, params["boundary"]) for { part, err := mr.NextPart() if err != nil { // may be io.EOF return "", err } - partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type")) + partContentType, partParams, err := mime.ParseMediaType(part.Header.Get("Content-Type")) if err != nil { return "", err - } else if strings.ToLower(partContentType) != "text/plain" { - continue } - return readPlainTextMailBody(part, part.Header.Get("Content-Transfer-Encoding")) + if strings.ToLower(partContentType) == "text/plain" { + return readPlainTextMailBody(part, part.Header.Get("Content-Transfer-Encoding")) + } else if strings.HasPrefix(strings.ToLower(partContentType), "multipart/") { + return readMultipartMailBody(part, partParams, depth+1) + } + // Continue with next part } } diff --git a/server/smtp_server_test.go b/server/smtp_server_test.go index 8932d72..701ef21 100644 --- a/server/smtp_server_test.go +++ b/server/smtp_server_test.go @@ -390,10 +390,8 @@ L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg== writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") } -/* - func TestSmtpBackend_NestedMultipartBase64(t *testing.T) { - email := `EHLO example.com - +func TestSmtpBackend_NestedMultipartBase64(t *testing.T) { + email := `EHLO example.com MAIL FROM: test@mydomain.me RCPT TO: ntfy-mytopic@ntfy.sh DATA @@ -431,14 +429,69 @@ L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg== . ` - s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("This should not be called") - }) - defer s.Close() - defer c.Close() - writeAndReadUntilLine(t, email, c, scanner, "451 4.0.0 invalid address") - } -*/ + s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/mytopic", r.URL.Path) + require.Equal(t, "TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local", r.Header.Get("Title")) + require.Equal(t, "This is a test message from TrueNAS CORE.", readAll(t, r.Body)) + }) + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued") +} + +func TestSmtpBackend_NestedMultipartTooDeep(t *testing.T) { + email := `EHLO example.com +MAIL FROM: test@mydomain.me +RCPT TO: ntfy-mytopic@ntfy.sh +DATA +Content-Type: multipart/mixed; boundary="===============1==" +MIME-Version: 1.0 +Subject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local +From: =?utf-8?q?Robbie?= +To: test@mydomain.me +Date: Thu, 16 Feb 2023 01:04:00 -0000 +Message-ID: + +This is a multi-part message in MIME format. +--===============1== +Content-Type: multipart/alternative; boundary="===============2==" +MIME-Version: 1.0 + +--===============2== +Content-Type: multipart/alternative; boundary="===============3==" +MIME-Version: 1.0 + +--===============3== +Content-Type: text/plain; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: base64 + +VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4= + +--===============3== +Content-Type: text/html; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: base64 + +PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv +L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg== + +--===============3==-- + +--===============2==-- + +--===============1==-- +. +` + + s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("This should not be called") + }) + defer s.Close() + defer c.Close() + writeAndReadUntilLine(t, email, c, scanner, "554 5.0.0 Error: transaction failed, blame it on the weather: multipart message nested too deep") +} + type smtpHandlerFunc func(http.ResponseWriter, *http.Request) func newTestSMTPServer(t *testing.T, handler smtpHandlerFunc) (s *smtp.Server, c net.Conn, conf *Config, scanner *bufio.Scanner) {