mirror of
https://github.com/axllent/mailpit.git
synced 2026-06-27 22:46:09 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user