Security: Set a default 50MB p/m limit to prevent DoS via unlimited SMTP DATA and /api/v1/send body sizes (GHSA-fpxj-m5q8-fphw)

This is a configurable limit (in MB's) which can optionally be disabled by setting it to 0.
This commit is contained in:
Ralph Slooten
2026-05-12 17:22:00 +12:00
parent 499a543963
commit 136bdde953
5 changed files with 73 additions and 0 deletions

View File

@@ -92,6 +92,7 @@ func init() {
rootCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().StringVar(&config.MaxAge, "max-age", config.MaxAge, "Max age of messages in either (h)ours or (d)ays (eg: 3d)")
rootCmd.Flags().IntVar(&config.MaxMessageSize, "max-message-size", config.MaxMessageSize, "Maximum message size in MB (0 = unlimited)")
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-ID)")
rootCmd.Flags().StringVar(&logger.LogFile, "log-file", logger.LogFile, "Log output to file instead of stdout")
@@ -219,6 +220,9 @@ func initConfigFromEnv() {
if len(os.Getenv("MP_MAX_AGE")) > 0 {
config.MaxAge = os.Getenv("MP_MAX_AGE")
}
if len(os.Getenv("MP_MAX_MESSAGE_SIZE")) > 0 {
config.MaxMessageSize, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGE_SIZE"))
}
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
config.UseMessageDates = true
}

View File

@@ -125,6 +125,11 @@ var (
// however some servers accept more.
SMTPMaxRecipients = 100
// MaxMessageSize is the maximum size of an inbound message, in megabytes (MiB).
// Applies to both SMTP DATA payloads and the HTTP /api/v1/send body.
// 0 disables the limit (not recommended on network-reachable listeners).
MaxMessageSize = 50
// IgnoreDuplicateIDs will skip messages with the same ID
IgnoreDuplicateIDs bool
@@ -325,6 +330,10 @@ func VerifyConfig() error {
return errors.New("[ui] HTTP bind should be in the format of <ip>:<port>")
}
if MaxMessageSize == 0 {
logger.Log().Warnf("[smtpd] no message limit set, this is not recommended for network-reachable listeners")
}
// Web UI & API
if UIAuthFile != "" {
UIAuthFile = filepath.Clean(UIAuthFile)

View File

@@ -247,6 +247,10 @@ func listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler)
},
}
if config.MaxMessageSize > 0 {
srv.MaxSize = config.MaxMessageSize * 1024 * 1024
}
if config.Label != "" {
srv.AppName = fmt.Sprintf("Mailpit (%s)", config.Label)
}

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
@@ -42,11 +43,19 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
return
}
if config.MaxMessageSize > 0 {
r.Body = http.MaxBytesReader(w, r.Body, int64(config.MaxMessageSize)*1024*1024)
}
decoder := json.NewDecoder(r.Body)
data := sendMessageParams{}
if err := decoder.Decode(&data.Body); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
w.WriteHeader(http.StatusRequestEntityTooLarge)
}
httpJSONError(w, err.Error())
return
}

View File

@@ -328,6 +328,53 @@ func TestAPIv1Send(t *testing.T) {
assertEqual(t, `This is a plain text attachment`, string(attachmentBytes), "wrong Attachment content")
}
func TestAPIv1SendMaxMessageSize(t *testing.T) {
setup()
defer storage.Close()
r := apiRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
original := config.MaxMessageSize
defer func() { config.MaxMessageSize = original }()
config.MaxMessageSize = 1 // 1 MiB cap for the test
bigText := strings.Repeat("X", 2*1024*1024)
oversized := fmt.Sprintf(`{
"From": {"Email": "a@example.com"},
"To": [{"Email": "b@example.com"}],
"Subject": "oversize",
"Text": %q
}`, bigText)
t.Log("Sending oversize message via HTTP API (expect 413)")
req, err := http.NewRequest("POST", ts.URL+"/api/v1/send", strings.NewReader(oversized))
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("unexpected transport error: %s", err)
}
_ = resp.Body.Close()
assertEqual(t, http.StatusRequestEntityTooLarge, resp.StatusCode, "expected 413 for oversize body")
t.Log("Sending normal-sized message via HTTP API (expect 200)")
jsonData, _ := json.Marshal(testSendMessage)
if _, err := clientPost(ts.URL+"/api/v1/send", string(jsonData)); err != nil {
t.Errorf("expected success for in-bound payload, got: %s", err)
}
t.Log("Setting MaxMessageSize=0 (unlimited), oversize should now succeed")
config.MaxMessageSize = 0
if _, err := clientPost(ts.URL+"/api/v1/send", oversized); err != nil {
t.Errorf("expected success when MaxMessageSize=0, got: %s", err)
}
}
func TestSendAPIAuthMiddleware(t *testing.T) {
setup()
defer storage.Close()