From e871ebb64ac2920dc002dae526941d3fef791bb0 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Tue, 20 Oct 2026 16:03:42 +0200 Subject: [PATCH] fix(thumbnails): read the image part without the multipart form The handler holds the whole image in memory to decode it; the form kept a second copy, on disk for anything past its memory limit, for no gain. --- .../thumbnails/pkg/service/http/v0/push.go | 61 +++++++++++++++---- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/services/thumbnails/pkg/service/http/v0/push.go b/services/thumbnails/pkg/service/http/v0/push.go index d31025f579..3b3d234016 100644 --- a/services/thumbnails/pkg/service/http/v0/push.go +++ b/services/thumbnails/pkg/service/http/v0/push.go @@ -6,6 +6,7 @@ import ( "fmt" "image/gif" "io" + "mime/multipart" "net/http" "strconv" "strings" @@ -78,35 +79,39 @@ func (s Thumbnails) pushHandler(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, int64(s.maxBody)) } - if err := r.ParseMultipartForm(32 << 20); err != nil { + // The part is read by hand, not with ParseMultipartForm: the whole image is + // held in memory to decode it anyway, and the form would keep a second copy, + // on disk for anything past its memory limit. + part, err := imagePart(r) + if err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { - w.WriteHeader(http.StatusRequestEntityTooLarge) - fmt.Fprintln(w, "image larger than THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE") + writeTooLarge(w) return } - writeInvalid(w, "failed to parse multipart form") + writeInvalid(w, err.Error()) return } - - file, header, err := r.FormFile("image") - if err != nil { - writeInvalid(w, "missing image field in form data") - return - } - defer file.Close() + defer part.Close() // imagor's upload loader accepts image/* only (UPLOAD_LOADER_ACCEPT), so an // untyped part is refused here too. - if ct := header.Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/") { + if ct := part.Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/") { writeNotAcceptable(w, fmt.Sprintf("unsupported part type: %q", ct)) return } - imgData, err := io.ReadAll(file) + imgData, err := io.ReadAll(part) if err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + writeTooLarge(w) + + return + } + writeInvalid(w, "failed to read image") return } @@ -260,6 +265,36 @@ func (s Thumbnails) serveAnimation(w http.ResponseWriter, imgData []byte, req re writeImage(w, "image/gif", buf.Bytes()) } +// imagePart walks the multipart body up to the image field and hands back that +// part, unread. The body is bounded by the caller, so the walk cannot run away. +func imagePart(r *http.Request) (*multipart.Part, error) { + mr, err := r.MultipartReader() + if err != nil { + return nil, errors.New("failed to parse multipart form") + } + + for { + part, err := mr.NextPart() + if err != nil { + if errors.Is(err, io.EOF) { + return nil, errors.New("missing image field in form data") + } + + return nil, err + } + if part.FormName() == "image" { + return part, nil + } + part.Close() + } +} + +// writeTooLarge answers a body that outgrew THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE. +func writeTooLarge(w http.ResponseWriter) { + w.WriteHeader(http.StatusRequestEntityTooLarge) + fmt.Fprintln(w, "image larger than THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE") +} + // writeImage answers with the encoded image. func writeImage(w http.ResponseWriter, contentType string, data []byte) { w.Header().Set("Content-Type", contentType)