From 35079d182cc07f233b47a636c4d7c98e76b573e6 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Tue, 12 May 2026 16:18:44 +1200 Subject: [PATCH] Security: Fix for path traversal & arbitrary file write in mailpit dump --http via attacker-controlled message IDs (GHSA-qx5x-85p8-vg4j) This fix also adds HTTP data limits to prevent excessively large files being transmitted by an attacker-controlled server (fake Mailpit). --- internal/dump/dump.go | 44 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/internal/dump/dump.go b/internal/dump/dump.go index 3ff90f6..9cf38d7 100644 --- a/internal/dump/dump.go +++ b/internal/dump/dump.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path" + "path/filepath" "regexp" "strings" @@ -18,9 +19,20 @@ import ( "github.com/axllent/mailpit/server/apiv1" ) +// maxRawSize caps the bytes read per remote message to prevent a hostile +// server from exhausting local disk via an unbounded response body. +const maxRawSize = 50 * 1024 * 1024 // 50 MiB + +// maxSummarySize caps the bytes read from the remote messages-summary endpoint +// to prevent a hostile server from exhausting memory via an unbounded response. +const maxSummarySize = 1000 * 1024 * 1024 // 1000 MiB + var ( linkRe = regexp.MustCompile(`(?i)^https?:\/\/`) + // idRe matches a valid Mailpit message ID (alphanumeric or dash, 8–60 chars). + idRe = regexp.MustCompile(`^[a-zA-Z0-9-]{8,60}$`) + outDir string // Base URL of mailpit instance @@ -35,7 +47,7 @@ var ( // Sync will sync all messages from the specified database or API to the specified output directory func Sync(d string) error { - outDir = path.Clean(d) + outDir = filepath.Clean(d) if URL != "" { if !linkRe.MatchString(URL) { @@ -77,12 +89,21 @@ func loadIDs() error { return err } - body, err := io.ReadAll(res.Body) + if res.StatusCode != http.StatusOK { + res.Body.Close() + return errors.New("error fetching messages summary: HTTP " + res.Status) + } + + body, err := io.ReadAll(io.LimitReader(res.Body, maxSummarySize+1)) if err != nil { return err } + if int64(len(body)) > maxSummarySize { + return errors.New("messages summary exceeds size cap") + } + var data apiv1.MessagesSummary if err := json.Unmarshal(body, &data); err != nil { return err @@ -117,6 +138,11 @@ func loadIDs() error { func saveMessages() error { for _, m := range summary { + if !idRe.MatchString(m.ID) { + logger.Log().Errorf("skipping message with invalid ID: %q", m.ID) + continue + } + out := path.Join(outDir, m.ID+".eml") // skip if message exists @@ -134,12 +160,24 @@ func saveMessages() error { continue } - b, err = io.ReadAll(res.Body) + if res.StatusCode != http.StatusOK { + res.Body.Close() + logger.Log().Errorf("error fetching message %s: HTTP %d", m.ID, res.StatusCode) + continue + } + + b, err = io.ReadAll(io.LimitReader(res.Body, maxRawSize+1)) + res.Body.Close() if err != nil { logger.Log().Errorf("error fetching message %s: %s", m.ID, err.Error()) continue } + + if len(b) > maxRawSize { + logger.Log().Errorf("message %s exceeds size cap (%d bytes), skipping", m.ID, maxRawSize) + continue + } } else { var err error b, err = storage.GetMessageRaw(m.ID)