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]
