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