This is an automated email from the ASF dual-hosted git repository. chibenwa pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-mime4j.git
commit 4383a37b469fd2861c3b3143d10ae628f103ae7b Author: Benoit TELLIER <[email protected]> AuthorDate: Wed Nov 4 14:56:55 2026 +0100 [FIX] stop() inside a multipart: read nothing more and end the multipart stop() truncates the root buffer, but only its read() and readLine() checked the truncation. BufferedLineReaderInputStream#fillBuffer, used directly by MimeBoundaryInputStream, kept reading from the caller's stream. The boundary stream then found the next delimiter in the refill, its part ended without eof nor last part, and MimeEntity opened the next part at the same position: new body parts after stop(), and with some reads an endless loop (DefaultMessageBuilder#parseHeader never returned). - BufferedLineReaderInputStream#fillBuffer returns -1 once truncated. - MimeBoundaryInputStream treats a truncated buffer as the end of the stream: eof is set, nothing is skipped, so the multipart ends with T_END_MULTIPART. - MimeEntity does not report a part cut by stop() as a premature end, which strict parsing turned into an error. Found by twake-fuzz, scenarios 30-stop-contract and 31-parse-header-matches-message. --- .../mime4j/io/BufferedLineReaderInputStream.java | 4 ++ .../james/mime4j/io/MimeBoundaryInputStream.java | 16 ++++++ .../org/apache/james/mime4j/stream/MimeEntity.java | 7 ++- .../james/mime4j/parser/MimeStreamParserTest.java | 54 +++++++++++++++++++ .../DefaultMessageBuilderParseHeaderTest.java | 60 ++++++++++++++++++++++ 5 files changed, 140 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/org/apache/james/mime4j/io/BufferedLineReaderInputStream.java b/core/src/main/java/org/apache/james/mime4j/io/BufferedLineReaderInputStream.java index c03b5867..1c360129 100644 --- a/core/src/main/java/org/apache/james/mime4j/io/BufferedLineReaderInputStream.java +++ b/core/src/main/java/org/apache/james/mime4j/io/BufferedLineReaderInputStream.java @@ -99,6 +99,10 @@ public class BufferedLineReaderInputStream extends LineReaderInputStream { } public int fillBuffer() throws IOException { + if (this.truncated) { + // stop(): nothing more is read from the source + return -1; + } // compact the buffer if necessary if (this.bufpos > 0) { // could swtich to (this.buffer.length / 2) but needs a 4*boundary capacity, then (instead of 2). int len = bufferLen(); diff --git a/core/src/main/java/org/apache/james/mime4j/io/MimeBoundaryInputStream.java b/core/src/main/java/org/apache/james/mime4j/io/MimeBoundaryInputStream.java index e95aa44c..609a5d9c 100644 --- a/core/src/main/java/org/apache/james/mime4j/io/MimeBoundaryInputStream.java +++ b/core/src/main/java/org/apache/james/mime4j/io/MimeBoundaryInputStream.java @@ -145,6 +145,12 @@ public class MimeBoundaryInputStream extends LineReaderInputStream { if (completed) { return false; } + if (!buffer.readAllowed()) { + // The buffer was truncated by stop(): the part ends here, without its delimiter + eof = true; + completed = true; + return false; + } if (endOfStream() && !hasData()) { skipBoundary(); verifyEndOfStream(); @@ -239,6 +245,11 @@ public class MimeBoundaryInputStream extends LineReaderInputStream { if (eof) { return -1; } + if (!buffer.readAllowed()) { + // Truncated by stop(): the buffered bytes are gone and no more are read + eof = true; + return -1; + } int bytesRead; if (!hasData()) { int before = buffer.pos(); @@ -385,6 +396,11 @@ public class MimeBoundaryInputStream extends LineReaderInputStream { private void skipBoundary() throws IOException { if (!completed) { completed = true; + if (!buffer.readAllowed()) { + // Truncated by stop(): no delimiter left to skip + eof = true; + return; + } buffer.skip(boundaryLen); boolean checkForLastPart = true; for (;;) { diff --git a/core/src/main/java/org/apache/james/mime4j/stream/MimeEntity.java b/core/src/main/java/org/apache/james/mime4j/stream/MimeEntity.java index f418b732..28f384ce 100644 --- a/core/src/main/java/org/apache/james/mime4j/stream/MimeEntity.java +++ b/core/src/main/java/org/apache/james/mime4j/stream/MimeEntity.java @@ -77,6 +77,7 @@ class MimeEntity implements EntityStateMachine { private RecursionMode recursionMode; private MimeBoundaryInputStream currentMimePartStream; + private boolean stopped; private LineReaderInputStreamAdaptor dataStream; /** The limited view of {@link #dataStream}, shared by every reader of the current content. */ private InputStream limitedContentStream; @@ -168,6 +169,7 @@ class MimeEntity implements EntityStateMachine { } public void stopSoft() { + this.stopped = true; if(currentMimePartStream != null) { try { currentMimePartStream.close(); @@ -355,7 +357,10 @@ class MimeEntity implements EntityStateMachine { // Removed the T_IN_BODYPART state (always use T_PREAMBLE) advanceToBoundary(); if (currentMimePartStream.eof() && !currentMimePartStream.isLastPart()) { - monitor(Event.MIME_BODY_PREMATURE_END); + if (!stopped) { + // A part cut short by stop() is not a malformed message + monitor(Event.MIME_BODY_PREMATURE_END); + } } else { if (!currentMimePartStream.isLastPart()) { clearMimePartStream(); diff --git a/core/src/test/java/org/apache/james/mime4j/parser/MimeStreamParserTest.java b/core/src/test/java/org/apache/james/mime4j/parser/MimeStreamParserTest.java index 74e2c36e..5a74a9f8 100644 --- a/core/src/test/java/org/apache/james/mime4j/parser/MimeStreamParserTest.java +++ b/core/src/test/java/org/apache/james/mime4j/parser/MimeStreamParserTest.java @@ -31,6 +31,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.LinkedList; public class MimeStreamParserTest { @@ -485,4 +486,57 @@ public class MimeStreamParserTest { return ContentUtil.decode(byteSequence); } + + @Test(timeout = 10000) + public void stopAtTheEndOfTheHeaderShouldNotReadMoreFromAChunkedSource() throws Exception { + final byte[] msg = ("From: [email protected]\nContent-Type: multipart/mixed; boundary=b1\n\n" + + "--b1\nX-Part: one\n\nfirst body\n--b1\nX-Part: two\n\nsecond body\n--b1--\n") + .getBytes(StandardCharsets.US_ASCII); + final int[] delivered = {0}; + InputStream source = new InputStream() { + // returns at most 8 bytes per read, like a socket + @Override + public int read() { + return delivered[0] < msg.length ? msg[delivered[0]++] & 0xff : -1; + } + + @Override + public int read(byte[] b, int off, int len) { + if (delivered[0] >= msg.length) { + return -1; + } + int n = Math.min(Math.min(len, 8), msg.length - delivered[0]); + System.arraycopy(msg, delivered[0], b, off, n); + delivered[0] += n; + return n; + } + }; + final MimeStreamParser parser = new MimeStreamParser(); + final int[] bodyParts = {0}; + final int[] endMultipart = {0}; + final int[] atStop = {-1}; + parser.setContentHandler(new AbstractContentHandler() { + @Override + public void endHeader() { + atStop[0] = delivered[0]; + parser.stop(); + } + + @Override + public void startBodyPart() { + bodyParts[0]++; + } + + @Override + public void endMultipart() { + endMultipart[0]++; + } + }); + + parser.parse(source); + + Assert.assertEquals(0, bodyParts[0]); + Assert.assertEquals(1, endMultipart[0]); + Assert.assertEquals(atStop[0], delivered[0]); + } } diff --git a/dom/src/test/java/org/apache/james/mime4j/message/DefaultMessageBuilderParseHeaderTest.java b/dom/src/test/java/org/apache/james/mime4j/message/DefaultMessageBuilderParseHeaderTest.java index 56b9662f..f6b2bd9a 100644 --- a/dom/src/test/java/org/apache/james/mime4j/message/DefaultMessageBuilderParseHeaderTest.java +++ b/dom/src/test/java/org/apache/james/mime4j/message/DefaultMessageBuilderParseHeaderTest.java @@ -22,9 +22,11 @@ package org.apache.james.mime4j.message; import static org.assertj.core.api.Assertions.assertThat; import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import org.apache.james.mime4j.dom.Header; +import org.apache.james.mime4j.stream.MimeConfig; import org.junit.Test; public class DefaultMessageBuilderParseHeaderTest { @@ -49,4 +51,62 @@ public class DefaultMessageBuilderParseHeaderTest { assertThat(header.getField("Subject").getBody()).isEqualTo("test"); assertThat(header.getFields()).hasSize(2); } + + private static String twoPartMessage(int firstBodyLines) { + StringBuilder body = new StringBuilder(); + for (int i = 0; i < firstBodyLines; i++) { + body.append("0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789\n"); + } + return "From: [email protected]\nContent-Type: multipart/mixed; boundary=b1\n\n" + + "--b1\nX-Part: one\n\n" + body + "--b1\nX-Part: two\n\nsecond body\n--b1--\n"; + } + + /** Returns at most 8 bytes per read, like a socket. */ + private static InputStream chunked(final byte[] msg) { + return new InputStream() { + private int delivered; + + @Override + public int read() { + return delivered < msg.length ? msg[delivered++] & 0xff : -1; + } + + @Override + public int read(byte[] b, int off, int len) { + if (delivered >= msg.length) { + return -1; + } + int n = Math.min(Math.min(len, 8), msg.length - delivered); + System.arraycopy(msg, delivered, b, off, n); + delivered += n; + return n; + } + }; + } + + @Test(timeout = 10000) + public void parseHeaderShouldReturnOnAMultipartOfSeveralPartsLargerThanTheBuffer() throws Exception { + Header header = new DefaultMessageBuilder() + .parseHeader(new ByteArrayInputStream(twoPartMessage(50).getBytes(StandardCharsets.US_ASCII))); + + assertThat(header.getFields()).hasSize(2); + } + + @Test(timeout = 10000) + public void parseHeaderShouldReturnOnAMultipartReadInChunks() throws Exception { + Header header = new DefaultMessageBuilder() + .parseHeader(chunked(twoPartMessage(1).getBytes(StandardCharsets.US_ASCII))); + + assertThat(header.getFields()).hasSize(2); + } + + @Test(timeout = 10000) + public void strictParseHeaderShouldReturnOnAMultipartReadInChunks() throws Exception { + DefaultMessageBuilder builder = new DefaultMessageBuilder(); + builder.setMimeEntityConfig(MimeConfig.STRICT); + + Header header = builder.parseHeader(chunked(twoPartMessage(50).getBytes(StandardCharsets.US_ASCII))); + + assertThat(header.getFields()).hasSize(2); + } } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
