Files
mailpit/server/apiv1/send.go
Ralph Slooten f70df49798 Merge commit from fork
The fix for GHSA-fpxj-m5q8-fphw only capped POST /api/v1/send.
Four sibling endpoints (SetReadStatus, DeleteMessages, SetMessageTags,
ReleaseMessage) decoded json.NewDecoder(r.Body) with no size limit,
allowing an unauthenticated attacker to drive unbounded memory growth
via a large IDs array.

Apply a 5 MB cap in middleWareFunc so all current and future API
handlers inherit it automatically. POST /api/v1/send is exempt via a
bodyLimitKey context value set in sendAPIAuthMiddleware, preserving
its existing config.MaxMessageSize (default 50 MB) limit.

Also fix TestAPIv1SendMaxMessageSize, which was broken by a Go 1.26
change: json.Decoder now wraps reader errors in *json.SyntaxError
rather than returning *http.MaxBytesError directly, causing the
errors.As check to miss it and return 400 instead of 413. Reading
the body with io.ReadAll before decoding surfaces the raw error,
restoring correct 413 behaviour on Go 1.25 and 1.26.
2026-05-28 19:39:17 +12:00

204 lines
5.2 KiB
Go

package apiv1
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/mail"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/smtpd"
"github.com/axllent/mailpit/internal/tools"
"github.com/jhillyerd/enmime/v2"
)
// SendMessageHandler handles HTTP requests to send a new message
func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
// swagger:route POST /api/v1/send message SendMessageParams
//
// # Send a message
//
// Send a message via the HTTP API.
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: SendMessageResponse
// 400: JSONErrorResponse
if config.DemoMode {
httpJSONError(w, "this functionality has been disabled for demonstration purposes")
return
}
if config.MaxMessageSize > 0 {
r.Body = http.MaxBytesReader(w, r.Body, int64(config.MaxMessageSize)*1024*1024)
}
// Read body before decoding so that MaxBytesReader errors are returned directly.
// In Go 1.26+, json.Decoder wraps reader errors in *json.SyntaxError, which
// prevents errors.As from finding *http.MaxBytesError to return a 413.
body, err := io.ReadAll(r.Body)
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
w.WriteHeader(http.StatusRequestEntityTooLarge)
}
httpJSONError(w, err.Error())
return
}
data := sendMessageParams{}
if err := json.NewDecoder(bytes.NewReader(body)).Decode(&data.Body); err != nil {
httpJSONError(w, err.Error())
return
}
var httpAuthUser *string
if user, _, ok := r.BasicAuth(); ok {
httpAuthUser = &user
}
id, err := data.Send(r.RemoteAddr, httpAuthUser)
if err != nil {
httpJSONError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(struct{ ID string }{ID: id}); err != nil {
httpError(w, err.Error())
}
}
// Send will validate the message structure and attempt to send to Mailpit.
// It returns a sending summary or an error.
func (d sendMessageParams) Send(remoteAddr string, httpAuthUser *string) (string, error) {
ip, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return "", fmt.Errorf("error parsing request RemoteAddr: %s", err.Error())
}
ipAddr := &net.IPAddr{IP: net.ParseIP(ip)}
addresses := []string{}
msg := enmime.Builder().
From(d.Body.From.Name, d.Body.From.Email).
Subject(d.Body.Subject).
Text([]byte(d.Body.Text))
if d.Body.HTML != "" {
msg = msg.HTML([]byte(d.Body.HTML))
}
if len(d.Body.To) > 0 {
for _, a := range d.Body.To {
if _, err := mail.ParseAddress(a.Email); err == nil {
msg = msg.To(a.Name, a.Email)
addresses = append(addresses, a.Email)
} else {
return "", fmt.Errorf("invalid To address: %s", a.Email)
}
}
}
if len(d.Body.Cc) > 0 {
for _, a := range d.Body.Cc {
if _, err := mail.ParseAddress(a.Email); err == nil {
msg = msg.CC(a.Name, a.Email)
addresses = append(addresses, a.Email)
} else {
return "", fmt.Errorf("invalid Cc address: %s", a.Email)
}
}
}
if len(d.Body.Bcc) > 0 {
for _, e := range d.Body.Bcc {
if _, err := mail.ParseAddress(e); err == nil {
msg = msg.BCC("", e)
addresses = append(addresses, e)
} else {
return "", fmt.Errorf("invalid Bcc address: %s", e)
}
}
}
if len(d.Body.ReplyTo) > 0 {
for _, a := range d.Body.ReplyTo {
if _, err := mail.ParseAddress(a.Email); err == nil {
msg = msg.ReplyTo(a.Name, a.Email)
} else {
return "", fmt.Errorf("invalid Reply-To address: %s", a.Email)
}
}
}
restrictedHeaders := []string{"To", "From", "Cc", "Bcc", "Reply-To", "Date", "Subject", "Content-Type", "Mime-Version"}
if len(d.Body.Tags) > 0 {
msg = msg.Header("X-Tags", strings.Join(d.Body.Tags, ", "))
restrictedHeaders = append(restrictedHeaders, "X-Tags")
}
if len(d.Body.Headers) > 0 {
for k, v := range d.Body.Headers {
// check header isn't in "restricted" headers
if tools.InArray(k, restrictedHeaders) {
return "", fmt.Errorf("cannot overwrite header: \"%s\"", k)
}
msg = msg.Header(k, v)
}
}
if len(d.Body.Attachments) > 0 {
for _, a := range d.Body.Attachments {
// workaround: split string because JS readAsDataURL() returns the base64 string
// with the mime type prefix eg: data:image/png;base64,<base64String>
parts := strings.Split(a.Content, ",")
content := parts[len(parts)-1]
b, err := base64.StdEncoding.DecodeString(content)
if err != nil {
return "", fmt.Errorf("error decoding base64 attachment \"%s\": %s", a.Filename, err.Error())
}
contentType := http.DetectContentType(b)
if a.ContentType != "" {
contentType = a.ContentType
}
if a.ContentID != "" {
msg = msg.AddInline(b, contentType, a.Filename, a.ContentID)
} else {
msg = msg.AddAttachment(b, contentType, a.Filename)
}
}
}
part, err := msg.Build()
if err != nil {
return "", fmt.Errorf("error building message: %s", err.Error())
}
var buff bytes.Buffer
if err := part.Encode(io.Writer(&buff)); err != nil {
return "", fmt.Errorf("error building message: %s", err.Error())
}
return smtpd.SaveToDatabase(ipAddr, d.Body.From.Email, addresses, buff.Bytes(), httpAuthUser)
}