diff --git a/internal/email/charset.go b/internal/email/charset.go index f70718a..63c2b55 100644 --- a/internal/email/charset.go +++ b/internal/email/charset.go @@ -1,5 +1,7 @@ package email +import "strings" + // windows-1251 (and its ASCII-compatible low half) is decoded here rather than // pulled in from x/text. // @@ -35,14 +37,19 @@ var cp1251High = [128]rune{ // decodeCP1251 maps each byte through the table. Every byte has a defined // meaning in this charset, so decoding cannot fail. +// +// It writes into a Builder rather than collecting runes: a []rune of the whole +// body is four bytes a character and was then copied again into the string, so +// a 1 MiB cp1251 mail allocated about 6 MiB to produce roughly 2. func decodeCP1251(b []byte) string { - out := make([]rune, 0, len(b)) + var out strings.Builder + out.Grow(len(b)) for _, c := range b { if c < 0x80 { - out = append(out, rune(c)) + out.WriteByte(c) continue } - out = append(out, cp1251High[c-0x80]) + out.WriteRune(cp1251High[c-0x80]) } - return string(out) + return out.String() } diff --git a/internal/email/message.go b/internal/email/message.go index 5219d39..ffb1c6f 100644 --- a/internal/email/message.go +++ b/internal/email/message.go @@ -18,6 +18,7 @@ package email import ( + "bytes" "encoding/base64" "fmt" "io" @@ -57,7 +58,10 @@ type Message struct { // through, because a subject line alone is often the whole task ("Счёт за // интернет"). Only a message whose headers cannot be read at all is an error. func ParseMessage(uid uint32, raw []byte) (Message, error) { - m, err := mail.ReadMessage(strings.NewReader(string(raw))) + // bytes.NewReader, not strings.NewReader(string(raw)): the conversion copied + // the whole message, and MaxMessageBytes lets that be 2 MiB per mail on a box + // already holding the resident model. + m, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { return Message{}, fmt.Errorf("email: parse message: %w", err) } @@ -82,6 +86,23 @@ func ParseMessage(uid uint32, raw []byte) (Message, error) { // wholesale — an attachment is a file, not a sentence, and reading one would // mean parsing arbitrary formats from the network. func plaintextBody(contentType, encoding string, body io.Reader) (string, error) { + return plaintextBodyAt(contentType, encoding, body, 0) +} + +// MaxMIMEDepth — how deep the MIME tree is walked. +// +// The nesting comes off the wire, so the recursion depth is the sender's to +// pick: a boundary line is a few bytes, and one message inside MaxMessageBytes +// can declare tens of thousands of multipart levels. Real mail is three deep +// (mixed, then alternative, then related), so a message past this is malformed +// or hostile and truncating the walk costs a body nobody was going to read. +const MaxMIMEDepth = 12 + +// plaintextBodyAt is plaintextBody carrying the current nesting depth. +func plaintextBodyAt(contentType, encoding string, body io.Reader, depth int) (string, error) { + if depth > MaxMIMEDepth { + return "", nil + } mediaType, params, err := mime.ParseMediaType(contentType) if contentType == "" || err != nil { // No Content-Type at all is legal and means text/plain; a broken one is @@ -94,7 +115,7 @@ func plaintextBody(contentType, encoding string, body io.Reader) (string, error) if boundary == "" { return "", fmt.Errorf("email: multipart without boundary") } - plain, html, err := multipartText(multipart.NewReader(body, boundary)) + plain, html, err := multipartText(multipart.NewReader(body, boundary), depth+1) if err != nil { return "", err } @@ -124,7 +145,7 @@ func plaintextBody(contentType, encoding string, body io.Reader) (string, error) // contribute either kind. Folding a nested level's answer into one string put // HTML-derived text in the plain bucket, and a real text/plain sibling later in // the message was then thrown away by the "plain is already set" guard. -func multipartText(mr *multipart.Reader) (plain, html string, err error) { +func multipartText(mr *multipart.Reader, depth int) (plain, html string, err error) { for { part, err := mr.NextPart() if err == io.EOF { @@ -143,8 +164,8 @@ func multipartText(mr *multipart.Reader) (plain, html string, err error) { switch { case strings.HasPrefix(mediaType, "multipart/"): var np, nh string - if b := params["boundary"]; b != "" { - np, nh, _ = multipartText(multipart.NewReader(part, b)) + if b := params["boundary"]; b != "" && depth <= MaxMIMEDepth { + np, nh, _ = multipartText(multipart.NewReader(part, b), depth+1) } part.Close() if plain == "" { @@ -154,7 +175,7 @@ func multipartText(mr *multipart.Reader) (plain, html string, err error) { html = nh } default: - text, terr := plaintextBody(ct, part.Header.Get("Content-Transfer-Encoding"), part) + text, terr := plaintextBodyAt(ct, part.Header.Get("Content-Transfer-Encoding"), part, depth) part.Close() if terr != nil || strings.TrimSpace(text) == "" { continue diff --git a/internal/email/message_test.go b/internal/email/message_test.go index c961dce..ec11d14 100644 --- a/internal/email/message_test.go +++ b/internal/email/message_test.go @@ -1,6 +1,7 @@ package email import ( + "fmt" "os" "path/filepath" "strings" @@ -143,6 +144,24 @@ func TestParseTruncatesLongBody(t *testing.T) { } } +// Nesting depth comes off the wire, so a hostile message must not get to pick +// the recursion depth. The walk stops and the headers still come through. +func TestParseMessageBoundsMIMEDepth(t *testing.T) { + var b strings.Builder + b.WriteString("Subject: deep\r\nMIME-Version: 1.0\r\n") + for i := 0; i < MaxMIMEDepth+20; i++ { + fmt.Fprintf(&b, "Content-Type: multipart/mixed; boundary=\"b%d\"\r\n\r\n--b%d\r\n", i, i) + } + b.WriteString("Content-Type: text/plain\r\n\r\nглубоко\r\n") + msg, err := ParseMessage(7, []byte(b.String())) + if err != nil { + t.Fatalf("ParseMessage: %v", err) + } + if msg.Subject != "deep" { + t.Errorf("Subject = %q, want the headers to survive", msg.Subject) + } +} + func TestCollapseSqueezesBlankLines(t *testing.T) { got := collapse(" a b \r\n\r\n\r\n\r\n c \r\n") if got != "a b\n\nc" {