From 1ea92339e2aef2dedb5a88ac41d493c25b0d985b Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Thu, 10 Sep 2026 14:20:06 +0200 Subject: [PATCH] Emit empty multipart body for all cases Prior to this commit, gh-30953 fixed a case where multipart file parts were not emitted properly when the body itself is empty. There are other cases like this, depending on the order and slicing of data buffers received by the parser. Here, a buffer containing the entire boundary would not cause an empty file part to be emitted and instead switch to the next header. This commit ensures that empty file parts are always emitted as they should. Fixes gh-37264 --- .../http/codec/multipart/MultipartParser.java | 14 +++++++++++++- .../DefaultPartHttpMessageReaderTests.java | 18 +++++++++++++++++- .../codec/multipart/empty-part-last.multipart | 13 +++++++++++++ .../http/codec/multipart/empty-part.multipart | 16 ++++++---------- 4 files changed, 49 insertions(+), 12 deletions(-) create mode 100644 spring-web/src/test/resources/org/springframework/http/codec/multipart/empty-part-last.multipart diff --git a/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartParser.java b/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartParser.java index 24df791d90f..879b05c41a5 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartParser.java +++ b/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartParser.java @@ -546,6 +546,7 @@ final class MultipartParser extends BaseSubscriber { // iterate over buffers in reverse order DataBufferUtils.release(boundaryBuffer); DataBuffer prev; + boolean found = false; while ((prev = this.queue.pollLast()) != null) { int prevByteCount = prev.readableByteCount(); int prevLen = prevByteCount + len; @@ -555,6 +556,7 @@ final class MultipartParser extends BaseSubscriber { DataBufferUtils.release(prev); enqueue(body); flush(); + found = true; break; } else { @@ -563,11 +565,21 @@ final class MultipartParser extends BaseSubscriber { len += prevByteCount; } } + if (!found) { + // all buffered bytes were boundary bytes: the part had an empty body + emitBody(buffer.factory().allocateBuffer(0), true); + } } else /* if (len == 0) */ { // buffer starts with complete delimiter, flush out the previous buffers DataBufferUtils.release(boundaryBuffer); - flush(); + if (this.queue.isEmpty()) { + // nothing was ever buffered for this part: the part had an empty body + emitBody(buffer.factory().allocateBuffer(0), true); + } + else { + flush(); + } } changeState(this, new HeadersState(), buffer); diff --git a/spring-web/src/test/java/org/springframework/http/codec/multipart/DefaultPartHttpMessageReaderTests.java b/spring-web/src/test/java/org/springframework/http/codec/multipart/DefaultPartHttpMessageReaderTests.java index 2212c33307d..e7b468cafa7 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/multipart/DefaultPartHttpMessageReaderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/multipart/DefaultPartHttpMessageReaderTests.java @@ -317,7 +317,7 @@ class DefaultPartHttpMessageReaderTests extends AbstractLeakCheckingTests { @ParameterizedDefaultPartHttpMessageReaderTest void emptyLastPart(DefaultPartHttpMessageReader reader) throws InterruptedException { MockServerHttpRequest request = createRequest( - new ClassPathResource("empty-part.multipart", getClass()), "LiG0chJ0k7YtLt-FzTklYFgz50i88xJCW5jD"); + new ClassPathResource("empty-part-last.multipart", getClass()), "LiG0chJ0k7YtLt-FzTklYFgz50i88xJCW5jD"); Flux result = reader.read(forClass(Part.class), request, emptyMap()); @@ -330,6 +330,22 @@ class DefaultPartHttpMessageReaderTests extends AbstractLeakCheckingTests { latch.await(); } + @ParameterizedDefaultPartHttpMessageReaderTest // gh-37264 + void emptyPartNotLast(DefaultPartHttpMessageReader reader) throws InterruptedException { + MockServerHttpRequest request = createRequest( + new ClassPathResource("empty-part.multipart", getClass()), "simple-boundary"); + + Flux result = reader.read(forClass(Part.class), request, emptyMap()); + + CountDownLatch latch = new CountDownLatch(2); + StepVerifier.create(result) + .consumeNextWith(part -> testPart(part, "file", "", latch)).as("file") + .consumeNextWith(part -> testPart(part, "action", "asd", latch)).as("action") + .verifyComplete(); + + latch.await(); + } + private void testBrowser(DefaultPartHttpMessageReader reader, Resource resource, String boundary) throws InterruptedException { diff --git a/spring-web/src/test/resources/org/springframework/http/codec/multipart/empty-part-last.multipart b/spring-web/src/test/resources/org/springframework/http/codec/multipart/empty-part-last.multipart new file mode 100644 index 00000000000..501388b7819 --- /dev/null +++ b/spring-web/src/test/resources/org/springframework/http/codec/multipart/empty-part-last.multipart @@ -0,0 +1,13 @@ +--LiG0chJ0k7YtLt-FzTklYFgz50i88xJCW5jD +Content-Disposition: form-data; name="files"; filename="file17312898095703516893.tmp" +Content-Type: application/octet-stream +Content-Length: 0 + + +--LiG0chJ0k7YtLt-FzTklYFgz50i88xJCW5jD +Content-Disposition: form-data; name="files"; filename="file14790463448453253614.tmp" +Content-Type: application/octet-stream +Content-Length: 0 + + +--LiG0chJ0k7YtLt-FzTklYFgz50i88xJCW5jD-- diff --git a/spring-web/src/test/resources/org/springframework/http/codec/multipart/empty-part.multipart b/spring-web/src/test/resources/org/springframework/http/codec/multipart/empty-part.multipart index 501388b7819..6613946f0d8 100644 --- a/spring-web/src/test/resources/org/springframework/http/codec/multipart/empty-part.multipart +++ b/spring-web/src/test/resources/org/springframework/http/codec/multipart/empty-part.multipart @@ -1,13 +1,9 @@ ---LiG0chJ0k7YtLt-FzTklYFgz50i88xJCW5jD -Content-Disposition: form-data; name="files"; filename="file17312898095703516893.tmp" -Content-Type: application/octet-stream -Content-Length: 0 +--simple-boundary +Content-Disposition: form-data; name="file"; filename="test.txt" ---LiG0chJ0k7YtLt-FzTklYFgz50i88xJCW5jD -Content-Disposition: form-data; name="files"; filename="file14790463448453253614.tmp" -Content-Type: application/octet-stream -Content-Length: 0 +--simple-boundary +Content-Disposition: form-data; name="action" - ---LiG0chJ0k7YtLt-FzTklYFgz50i88xJCW5jD-- +asd +--simple-boundary--