email: bound the MIME walk and drop two copies of every body (V-581)
The MIME tree walk had no depth limit, and the nesting comes off the wire. A boundary line is a few bytes, so one message inside MaxMessageBytes can declare tens of thousands of multipart levels and pick the recursion depth of a daemon reading his mail. MaxMIMEDepth stops the walk at 12, well past the three levels real mail uses, and the headers still come through. ParseMessage converted the raw message to a string to read it, which copied up to 2 MiB per mail on a box already holding the resident model. It reads the bytes directly now. decodeCP1251 collected runes and then copied them into a string, four bytes a character for the whole body, and writes into a Builder instead. No behaviour change to what is read: EXAMINE and BODY.PEEK are still the only mailbox commands, and no credential reaches a log line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
package email
|
package email
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
// windows-1251 (and its ASCII-compatible low half) is decoded here rather than
|
// windows-1251 (and its ASCII-compatible low half) is decoded here rather than
|
||||||
// pulled in from x/text.
|
// 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
|
// decodeCP1251 maps each byte through the table. Every byte has a defined
|
||||||
// meaning in this charset, so decoding cannot fail.
|
// 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 {
|
func decodeCP1251(b []byte) string {
|
||||||
out := make([]rune, 0, len(b))
|
var out strings.Builder
|
||||||
|
out.Grow(len(b))
|
||||||
for _, c := range b {
|
for _, c := range b {
|
||||||
if c < 0x80 {
|
if c < 0x80 {
|
||||||
out = append(out, rune(c))
|
out.WriteByte(c)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, cp1251High[c-0x80])
|
out.WriteRune(cp1251High[c-0x80])
|
||||||
}
|
}
|
||||||
return string(out)
|
return out.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
package email
|
package email
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -57,7 +58,10 @@ type Message struct {
|
|||||||
// through, because a subject line alone is often the whole task ("Счёт за
|
// through, because a subject line alone is often the whole task ("Счёт за
|
||||||
// интернет"). Only a message whose headers cannot be read at all is an error.
|
// интернет"). Only a message whose headers cannot be read at all is an error.
|
||||||
func ParseMessage(uid uint32, raw []byte) (Message, 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 {
|
if err != nil {
|
||||||
return Message{}, fmt.Errorf("email: parse message: %w", err)
|
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
|
// wholesale — an attachment is a file, not a sentence, and reading one would
|
||||||
// mean parsing arbitrary formats from the network.
|
// mean parsing arbitrary formats from the network.
|
||||||
func plaintextBody(contentType, encoding string, body io.Reader) (string, error) {
|
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)
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||||
if contentType == "" || err != nil {
|
if contentType == "" || err != nil {
|
||||||
// No Content-Type at all is legal and means text/plain; a broken one is
|
// 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 == "" {
|
if boundary == "" {
|
||||||
return "", fmt.Errorf("email: multipart without 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 {
|
if err != nil {
|
||||||
return "", err
|
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
|
// 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
|
// 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.
|
// 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 {
|
for {
|
||||||
part, err := mr.NextPart()
|
part, err := mr.NextPart()
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
@@ -143,8 +164,8 @@ func multipartText(mr *multipart.Reader) (plain, html string, err error) {
|
|||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(mediaType, "multipart/"):
|
case strings.HasPrefix(mediaType, "multipart/"):
|
||||||
var np, nh string
|
var np, nh string
|
||||||
if b := params["boundary"]; b != "" {
|
if b := params["boundary"]; b != "" && depth <= MaxMIMEDepth {
|
||||||
np, nh, _ = multipartText(multipart.NewReader(part, b))
|
np, nh, _ = multipartText(multipart.NewReader(part, b), depth+1)
|
||||||
}
|
}
|
||||||
part.Close()
|
part.Close()
|
||||||
if plain == "" {
|
if plain == "" {
|
||||||
@@ -154,7 +175,7 @@ func multipartText(mr *multipart.Reader) (plain, html string, err error) {
|
|||||||
html = nh
|
html = nh
|
||||||
}
|
}
|
||||||
default:
|
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()
|
part.Close()
|
||||||
if terr != nil || strings.TrimSpace(text) == "" {
|
if terr != nil || strings.TrimSpace(text) == "" {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package email
|
package email
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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) {
|
func TestCollapseSqueezesBlankLines(t *testing.T) {
|
||||||
got := collapse(" a b \r\n\r\n\r\n\r\n c \r\n")
|
got := collapse(" a b \r\n\r\n\r\n\r\n c \r\n")
|
||||||
if got != "a b\n\nc" {
|
if got != "a b\n\nc" {
|
||||||
|
|||||||
Reference in New Issue
Block a user