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 040d3a1595a8631b50cbc6d16c7831cd16ce0bad
Author: Benoit TELLIER <[email protected]>
AuthorDate: Thu Nov 5 05:32:38 2026 +0100

    [FIX] maxContentLen: a body part does not depend on how its source splits 
reads
    
    A body part left the rest of its content to its parent, which drained it
    through a new limited stream counting from 0. So only what a decoder had
    not read ahead was counted: with data after a base64 pad, a part over the
    limit was accepted or rejected depending on the size of the reads, and a
    message/rfc822 part on its position in the parser's buffer.
    
    A body part now drains its content itself, through the limited stream that
    already counted those bytes. An encapsulated message limits its entities
    one by one, so its own bodies are drained the same way, and what is left
    of it (data after the pad, an epilogue) is read and discarded without a
    limit: the encoded form of a message is not bounded as a whole.
---
 .../org/apache/james/mime4j/stream/MimeEntity.java |  53 +++++++--
 .../stream/MimeTokenStreamContentLimitTest.java    | 125 +++++++++++++++++++++
 2 files changed, 168 insertions(+), 10 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 ba9da79d..186bccf8 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
@@ -78,6 +78,10 @@ class MimeEntity implements EntityStateMachine {
     private RecursionMode recursionMode;
     private MimeBoundaryInputStream currentMimePartStream;
     private boolean stopped;
+    /** Whether this entity is a body part or an encapsulated message, not the 
root. */
+    private boolean nested;
+    /** Whether the content of this entity was parsed as an encapsulated 
message. */
+    private boolean encapsulating;
     private LineReaderInputStreamAdaptor dataStream;
     /** The limited view of {@link #dataStream}, shared by every reader of the 
current content. */
     private InputStream limitedContentStream;
@@ -341,6 +345,7 @@ class MimeEntity implements EntityStateMachine {
                     && MimeUtil.isMessage(mimeType) && !stopped) {
                 // Once stopped, the encapsulated message is not parsed: there 
is nothing left to read
                 state = EntityState.T_BODY;
+                encapsulating = true;
                 return nextMessage();
             } else {
                 state = EntityState.T_BODY;
@@ -389,6 +394,9 @@ class MimeEntity implements EntityStateMachine {
             state = EntityState.T_END_MULTIPART;
             break;
         case T_BODY:
+            drainContent();
+            state = endState;
+            break;
         case T_END_MULTIPART:
             state = endState;
             break;
@@ -431,17 +439,41 @@ class MimeEntity implements EntityStateMachine {
             if (tmpbuf == null) {
                 tmpbuf = getBufferRecycler().allocByteBuffer(0, 2048);
             }
-            InputStream instream = getLimitedContentStream();
-            try {
-                while (instream.read(tmpbuf)!= -1) {
-                }
-            } catch (MimeIOException e) {
-                if (e.getCause() instanceof MaxContentLengthLimitException) {
-                    // A parse error: report it as next() documents
-                    throw e.getCause();
-                }
-                throw e;
+            drain(getLimitedContentStream());
+        }
+    }
+
+    private void drain(InputStream instream) throws IOException, MimeException 
{
+        try {
+            while (instream.read(tmpbuf)!= -1) {
             }
+        } catch (MimeIOException e) {
+            if (e.getCause() instanceof MaxContentLengthLimitException) {
+                // A parse error: report it as next() documents
+                throw e.getCause();
+            }
+            throw e;
+        }
+    }
+
+    /**
+     * Applies the content limit to the whole content of a nested entity, here 
where the bytes
+     * already read are counted, including what a decoder read ahead. Left to 
the parent, the rest
+     * would count from 0, so the outcome would depend on the size of the 
reads. An encapsulated
+     * message limits its own entities: what is left of it after them (data 
after the base64 pad,
+     * an epilogue) is read and discarded, whatever its size.
+     */
+    private void drainContent() throws IOException, MimeException {
+        if (!nested || stopped || config.getMaxContentLen() <= 0) {
+            return;
+        }
+        if (encapsulating) {
+            if (tmpbuf == null) {
+                tmpbuf = getBufferRecycler().allocByteBuffer(0, 2048);
+            }
+            drain(dataStream);
+        } else {
+            advanceToBoundary();
         }
     }
 
@@ -481,6 +513,7 @@ class MimeEntity implements EntityStateMachine {
                     fieldBuilder,
                     bodyDescBuilder.newChild());
             mimeentity.setRecursionMode(recursionMode);
+            mimeentity.nested = true;
             return mimeentity;
         }
     }
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
new file mode 100644
index 00000000..cbe828f1
--- /dev/null
+++ 
b/core/src/test/java/org/apache/james/mime4j/stream/MimeTokenStreamContentLimitTest.java
@@ -0,0 +1,125 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.mime4j.stream;
+
+import java.io.ByteArrayInputStream;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.james.mime4j.MimeException;
+import org.apache.james.mime4j.io.MaxContentLengthLimitException;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class MimeTokenStreamContentLimitTest {
+    private static final String TAIL = "QQ==\n"
+        + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n"
+        + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n"
+        + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n"
+        + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n"
+        + "--b--\n";
+    private static final String MESSAGE = "Content-Type: multipart/mixed; 
boundary=b\n\n"
+        + "--b\nContent-Transfer-Encoding: base64\n\n" + TAIL;
+    private static final String ENCAPSULATED = "Content-Type: multipart/mixed; 
boundary=b\n\n"
+        + "--b\nContent-Type: message/rfc822\nContent-Transfer-Encoding: 
base64\n\n" + TAIL;
+
+    private static byte[] bytes(String message) {
+        return message.getBytes(StandardCharsets.US_ASCII);
+    }
+
+    private static void parse(InputStream source) throws IOException, 
MimeException {
+        MimeTokenStream stream = new 
MimeTokenStream(MimeConfig.custom().setMaxContentLen(100).build());
+        stream.setRecursionMode(RecursionMode.M_NO_RECURSE);
+        stream.parse(source);
+        byte[] buffer = new byte[256];
+        for (EntityState state = stream.getState(); state != 
EntityState.T_END_OF_STREAM; state = stream.next()) {
+            if (state == EntityState.T_BODY) {
+                InputStream body = stream.getDecodedInputStream();
+                while (body.read(buffer) != -1) {
+                    // drain
+                }
+            }
+        }
+    }
+
+    private static InputStream oneByteAtATime(byte[] bytes) {
+        return new FilterInputStream(new ByteArrayInputStream(bytes)) {
+            @Override
+            public int read(byte[] b, int off, int len) throws IOException {
+                return super.read(b, off, Math.min(len, 1));
+            }
+        };
+    }
+
+    private static String outcome(InputStream source, int maxContentLen) 
throws IOException {
+        MimeTokenStream stream = new 
MimeTokenStream(MimeConfig.custom().setMaxContentLen(maxContentLen).build());
+        stream.parse(source);
+        try {
+            while (stream.next() != EntityState.T_END_OF_STREAM) {
+                // the drain happens in next()
+            }
+            return "completed";
+        } catch (MimeException e) {
+            return e.getClass().getSimpleName();
+        }
+    }
+
+    @Test(expected = MaxContentLengthLimitException.class)
+    public void partOverTheLimitShouldFailWhenReadAtOnce() throws Exception {
+        parse(new ByteArrayInputStream(bytes(MESSAGE)));
+    }
+
+    @Test(expected = MaxContentLengthLimitException.class)
+    public void partOverTheLimitShouldFailWhenReadByteByByte() throws 
Exception {
+        parse(oneByteAtATime(bytes(MESSAGE)));
+    }
+
+    @Test
+    public void encodedEncapsulatedMessageShouldNotDependOnReadSizes() throws 
Exception {
+        Assert.assertEquals("completed", outcome(new 
ByteArrayInputStream(bytes(ENCAPSULATED)), 100));
+        Assert.assertEquals("completed", 
outcome(oneByteAtATime(bytes(ENCAPSULATED)), 100));
+    }
+
+    @Test
+    public void 
encodedEncapsulatedMessageShouldNotDependOnItsPositionInTheBuffer() throws 
Exception {
+        StringBuilder header = new StringBuilder();
+        for (int i = 0; i < 97; i++) {
+            header.append("X-Long: 0123456789012345678901234567890\n");
+        }
+        byte[] message = bytes(header + ENCAPSULATED);
+
+        Assert.assertEquals("completed", outcome(new 
ByteArrayInputStream(message), 100));
+    }
+
+    @Test
+    public void unreadBodyOfAnEncapsulatedMessageShouldStillBeLimited() throws 
Exception {
+        StringBuilder body = new StringBuilder();
+        for (int i = 0; i < 10; i++) {
+            body.append("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n");
+        }
+        String plain = "Content-Type: multipart/mixed; boundary=b\n\n"
+            + "--b\nContent-Type: message/rfc822\n\nSubject: a\n\n" + body + 
"--b--\n";
+
+        Assert.assertEquals("MaxContentLengthLimitException", outcome(new 
ByteArrayInputStream(bytes(plain)), 100));
+        Assert.assertEquals("MaxContentLengthLimitException", 
outcome(oneByteAtATime(bytes(plain)), 100));
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to