From 0692c77b75f51f8d940257eaed90a9ef3c2d3acc Mon Sep 17 00:00:00 2001 From: bintangakbarRK Date: Wed, 5 Aug 2026 20:34:25 +0700 Subject: [PATCH] fix: close multipart file handles after mime-type validation MimeTypeValidator.Validate opened the uploaded file to sniff its content type but never closed the handle. For parts stored on disk (above the adapter memory threshold) each call leaked a file descriptor until the GC finalizer ran, and readFile leaked its own handle on the validation-error path. Close both handles so upload bursts do not exhaust the descriptor limit. --- formdata.go | 2 + formdata_internal_test.go | 84 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 formdata_internal_test.go diff --git a/formdata.go b/formdata.go index 5285ff98..f0bb6b20 100644 --- a/formdata.go +++ b/formdata.go @@ -48,6 +48,7 @@ func (v MimeTypeValidator) Validate(fh *multipart.FileHeader, location string) ( if err != nil { return "", &ErrorDetail{Message: "Failed to open file", Location: location} } + defer file.Close() mimeType := fh.Header.Get("Content-Type") if mimeType == "" { @@ -178,6 +179,7 @@ func readFile( } contentType, validationErr := validator.Validate(fh, location) if validationErr != nil { + f.Close() return FormFile{}, validationErr } return FormFile{ diff --git a/formdata_internal_test.go b/formdata_internal_test.go new file mode 100644 index 00000000..08279866 --- /dev/null +++ b/formdata_internal_test.go @@ -0,0 +1,84 @@ +package huma + +import ( + "bytes" + "mime/multipart" + "runtime/debug" + "syscall" + "testing" + + "github.com/stretchr/testify/require" +) + +// limitFileDescriptors lowers the process soft RLIMIT_NOFILE and disables the +// garbage collector for the duration of the test. This makes leaked file +// handles observable: the GC finalizer would otherwise close unreachable +// handles and a high descriptor limit would absorb small leaks. The previous +// values are restored via t.Cleanup. +func limitFileDescriptors(t *testing.T) { + t.Helper() + var rl syscall.Rlimit + require.NoError(t, syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rl)) + orig := rl + if rl.Cur > 80 { + rl.Cur = 80 + } + require.NoError(t, syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rl)) + origGC := debug.SetGCPercent(-1) + t.Cleanup(func() { + debug.SetGCPercent(origGC) + syscall.Setrlimit(syscall.RLIMIT_NOFILE, &orig) + }) +} + +// diskBackedFileHeader parses a one-file-part multipart body with a zero-byte +// memory threshold, so the part is stored in a temporary file on disk and each +// FileHeader.Open returns a real *os.File. The returned form must be cleaned +// up with RemoveAll. +func diskBackedFileHeader(t *testing.T, contentType string) (*multipart.Form, *multipart.FileHeader) { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, err := w.CreateFormFile("file", "test.txt") + require.NoError(t, err) + _, err = part.Write([]byte("hello, world!")) + require.NoError(t, err) + require.NoError(t, w.Close()) + + form, err := multipart.NewReader(&buf, w.Boundary()).ReadForm(0) + require.NoError(t, err) + fh := form.File["file"][0] + fh.Header.Set("Content-Type", contentType) + return form, fh +} + +func TestMimeTypeValidatorClosesFile(t *testing.T) { + form, fh := diskBackedFileHeader(t, "text/plain") + defer form.RemoveAll() + limitFileDescriptors(t) + + validator := NewMimeTypeValidator(&Encoding{ContentType: "text/plain"}) + for range 200 { + _, detail := validator.Validate(fh, "file") + // A leaked handle per call exhausts the descriptor limit within the + // loop; with the fix every handle is closed and this never fails. + require.Nil(t, detail, "Validate unexpectedly failed: %v", detail) + } +} + +func TestReadFileClosesOnValidationError(t *testing.T) { + form, fh := diskBackedFileHeader(t, "text/plain") + defer form.RemoveAll() + limitFileDescriptors(t) + + validator := NewMimeTypeValidator(&Encoding{ContentType: "image/png"}) + for range 200 { + _, detail := readFile(fh, "file", validator) + // A leaked handle per call exhausts the descriptor limit within the + // loop and surfaces as "Failed to open file" instead of the expected + // mime-type error; with the fix every handle is closed and this never + // happens. + require.NotNil(t, detail) + require.Contains(t, detail.Message, "Invalid mime type") + } +}