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 994242bd2a27c6ea8c64a93407b47c23ba1ea3ff Author: Benoit TELLIER <[email protected]> AuthorDate: Thu Nov 5 12:28:44 2026 +0100 [FIX] MimeEntity: closing a body of an encapsulated message does not close its parent MimeStreamParser closes each body stream, and that close went down to what the encapsulated entity reads: the decoder of the message/rfc822 part, or the caller's stream. Since dab2f50f, leaving T_BODY reads what is left of the content when maxContentLen is set, so the read hit the closed stream. Under PERMISSIVE, parse() failed with an IOException on a base64 or quoted-printable message/rfc822 whose body the handler left unread, or holding another message/rfc822, and read the caller's stream after closing it without an encoding. The encapsulated entity now reads through a stream that ignores close(), as MimeBoundaryInputStream#close already does inside a multipart. Its parent closes the decoder once it has read its content. A body of a message/rfc822 therefore no longer closes the caller's stream. --- .../org/apache/james/mime4j/stream/MimeEntity.java | 32 +++++++- .../stream/MimeTokenStreamContentLimitTest.java | 88 ++++++++++++++++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) 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 186bccf8..dc02ef2c 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 @@ -19,6 +19,7 @@ package org.apache.james.mime4j.stream; +import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; import java.lang.ref.SoftReference; @@ -82,6 +83,8 @@ class MimeEntity implements EntityStateMachine { private boolean nested; /** Whether the content of this entity was parsed as an encapsulated message. */ private boolean encapsulating; + /** The decoder of the encapsulated message, closed once this entity has read its content. */ + private InputStream encapsulatedDecoder; private LineReaderInputStreamAdaptor dataStream; /** The limited view of {@link #dataStream}, shared by every reader of the current content. */ private InputStream limitedContentStream; @@ -395,6 +398,7 @@ class MimeEntity implements EntityStateMachine { break; case T_BODY: drainContent(); + closeEncapsulatedDecoder(); state = endState; break; case T_END_MULTIPART: @@ -481,8 +485,32 @@ class MimeEntity implements EntityStateMachine { // optimize nesting of streams returning the "lower" stream instead of // always return dataStream (that would add a LineReaderInputStreamAdaptor in the chain) InputStream instream = currentMimePartStream != null ? currentMimePartStream : inbuffer; - instream = decodedStream(instream); - return nextMimeEntity(EntityState.T_START_MESSAGE, EntityState.T_END_MESSAGE, instream); + InputStream decoded = decodedStream(instream); + if (decoded != instream) { + encapsulatedDecoder = decoded; + } + return nextMimeEntity(EntityState.T_START_MESSAGE, EntityState.T_END_MESSAGE, new CloseShield(decoded)); + } + + private void closeEncapsulatedDecoder() throws IOException { + if (encapsulatedDecoder != null) { + encapsulatedDecoder.close(); + encapsulatedDecoder = null; + } + } + + /** + * Closing a body of the encapsulated message (MimeStreamParser does) must not close what this + * entity reads after it, as MimeBoundaryInputStream#close guarantees inside a multipart. + */ + private static final class CloseShield extends FilterInputStream { + CloseShield(InputStream in) { + super(in); + } + + @Override + public void close() { + } } private InputStream decodedStream(InputStream instream) { diff --git a/core/src/test/java/org/apache/james/mime4j/stream/MimeTokenStreamContentLimitTest.java b/core/src/test/java/org/apache/james/mime4j/stream/MimeTokenStreamContentLimitTest.java index cbe828f1..9430152b 100644 --- a/core/src/test/java/org/apache/james/mime4j/stream/MimeTokenStreamContentLimitTest.java +++ b/core/src/test/java/org/apache/james/mime4j/stream/MimeTokenStreamContentLimitTest.java @@ -24,9 +24,12 @@ import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.Base64; import org.apache.james.mime4j.MimeException; import org.apache.james.mime4j.io.MaxContentLengthLimitException; +import org.apache.james.mime4j.parser.AbstractContentHandler; +import org.apache.james.mime4j.parser.MimeStreamParser; import org.junit.Assert; import org.junit.Test; @@ -122,4 +125,89 @@ public class MimeTokenStreamContentLimitTest { Assert.assertEquals("MaxContentLengthLimitException", outcome(new ByteArrayInputStream(bytes(plain)), 100)); Assert.assertEquals("MaxContentLengthLimitException", outcome(oneByteAtATime(bytes(plain)), 100)); } + + private static final String LEAF = "Content-Type: message/rfc822\n\nSubject: a\n\nbody\n"; + private static final String ENCODED_MESSAGE = "Content-Type: message/rfc822\n" + + "Content-Transfer-Encoding: quoted-printable\n\nSubject: a\n\nbody\n"; + + private static void parseReadingBodies(InputStream source) throws Exception { + MimeStreamParser parser = new MimeStreamParser(MimeConfig.PERMISSIVE); + parser.setContentHandler(new AbstractContentHandler() { + @Override + public void body(BodyDescriptor bd, InputStream is) throws IOException { + byte[] buffer = new byte[256]; + while (is.read(buffer) != -1) { + // read the whole body, as a DOM builder does + } + } + }); + parser.parse(source); + } + + private static InputStream failingOnceClosed(String message) { + return new FilterInputStream(new ByteArrayInputStream(bytes(message))) { + private boolean closed; + + @Override + public void close() { + closed = true; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (closed) { + throw new IOException("Stream closed"); + } + return super.read(b, off, len); + } + + @Override + public int read() throws IOException { + if (closed) { + throw new IOException("Stream closed"); + } + return super.read(); + } + }; + } + + @Test + public void closingABodyShouldNotFailAnEnclosingBase64Message() throws Exception { + String message = "Content-Type: message/rfc822\nContent-Transfer-Encoding: base64\n\n" + + Base64.getEncoder().encodeToString(bytes(LEAF)) + "\n"; + + parseReadingBodies(new ByteArrayInputStream(bytes(message))); + parseReadingBodies(new ByteArrayInputStream(bytes( + "Content-Type: multipart/mixed; boundary=b\n\n--b\n" + message + "--b--\n"))); + } + + @Test + public void closingABodyShouldNotLeadToReadingTheClosedSource() throws Exception { + parseReadingBodies(failingOnceClosed("Content-Type: message/rfc822\n\n" + LEAF)); + } + + @Test + public void aHandlerIgnoringTheBodyShouldNotFailAMessage() throws Exception { + for (String message : new String[] {ENCODED_MESSAGE, "Content-Type: message/rfc822\n\nSubject: a\n\nbody\n", + "Content-Type: multipart/mixed; boundary=b\n\n--b\n" + ENCODED_MESSAGE + "--b--\n"}) { + MimeStreamParser parser = new MimeStreamParser(MimeConfig.PERMISSIVE); + parser.setContentHandler(new AbstractContentHandler() { + }); + + parser.parse(failingOnceClosed(message)); + } + } + + @Test + public void aHandlerReadingPartOfTheBodyShouldNotFailAnEncodedMessage() throws Exception { + MimeStreamParser parser = new MimeStreamParser(MimeConfig.PERMISSIVE); + parser.setContentHandler(new AbstractContentHandler() { + @Override + public void body(BodyDescriptor bd, InputStream is) throws IOException { + is.read(); + } + }); + + parser.parse(new ByteArrayInputStream(bytes(ENCODED_MESSAGE))); + } } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
