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 fd7d38b1c9b5ac4b4912ac642b4c508b72e47d82
Author: Benoit TELLIER <[email protected]>
AuthorDate: Mon Aug 31 16:54:23 2026 +0200

    [FIX] Limit total header size to 1MB
---
 .../org/apache/james/mime4j/stream/MimeConfig.java | 46 ++++++++++-
 .../james/mime4j/stream/MimeTokenStream.java       | 35 +++++++-
 .../james/mime4j/stream/MimeEntityLimitsTest.java  | 95 ++++++++++++++++++++++
 .../james/mime4j/dom/LargeMessageParsingTest.java  | 39 +++++++--
 4 files changed, 205 insertions(+), 10 deletions(-)

diff --git a/core/src/main/java/org/apache/james/mime4j/stream/MimeConfig.java 
b/core/src/main/java/org/apache/james/mime4j/stream/MimeConfig.java
index e8a55b7f..f6e44b86 100644
--- a/core/src/main/java/org/apache/james/mime4j/stream/MimeConfig.java
+++ b/core/src/main/java/org/apache/james/mime4j/stream/MimeConfig.java
@@ -29,7 +29,7 @@ public final class MimeConfig {
     public static final MimeConfig PERMISSIVE = MimeConfig.custom()
         .setMaxContentLen(100 * 1024 * 1024)
         .setMaxHeaderCount(4096)
-        .setMaxHeaderLen(-1)
+        .setMaxHeaderLen(64 * 1024)
         .setMaxLineLen(-1)
         .build();
     public static final MimeConfig DEFAULT = new Builder().build();
@@ -44,6 +44,7 @@ public final class MimeConfig {
     private final int maxHeaderLen;
     private final long maxContentLen;
     private final int maxTotalHeaderCount;
+    private final long maxTotalHeaderLen;
     private final int maxPartCount;
     private final int maxNestingDepth;
     private final boolean countLineNumbers;
@@ -57,6 +58,7 @@ public final class MimeConfig {
             int maxHeaderLen,
             long maxContentLen,
             int maxTotalHeaderCount,
+            long maxTotalHeaderLen,
             int maxPartCount,
             int maxNestingDepth,
             boolean countLineNumbers,
@@ -70,6 +72,7 @@ public final class MimeConfig {
         this.maxHeaderLen = maxHeaderLen;
         this.maxContentLen = maxContentLen;
         this.maxTotalHeaderCount = maxTotalHeaderCount;
+        this.maxTotalHeaderLen = maxTotalHeaderLen;
         this.maxPartCount = maxPartCount;
         this.maxNestingDepth = maxNestingDepth;
         this.headlessParsing = headlessParsing;
@@ -151,6 +154,17 @@ public final class MimeConfig {
         return maxTotalHeaderCount;
     }
 
+    /**
+     * Returns the maximum total header length limit
+     *
+     * @see Builder#setMaxTotalHeaderLen(long)
+     *
+     * @return value of the maximum total header length limit
+     */
+    public long getMaxTotalHeaderLen() {
+        return maxTotalHeaderLen;
+    }
+
     /**
      * Returns the maximum part count limit
      *
@@ -202,6 +216,7 @@ public final class MimeConfig {
                 .append(", maxHeaderLen=").append(maxHeaderLen)
                 .append(", maxContentLen=").append(maxContentLen)
                 .append(", maxTotalHeaderCount=").append(maxTotalHeaderCount)
+                .append(", maxTotalHeaderLen=").append(maxTotalHeaderLen)
                 .append(", maxPartCount=").append(maxPartCount)
                 .append(", maxNestingDepth=").append(maxNestingDepth)
                 .append(", countLineNumbers=").append(countLineNumbers)
@@ -226,6 +241,7 @@ public final class MimeConfig {
             .setMaxHeaderLen(config.getMaxHeaderLen())
             .setMaxContentLen(config.getMaxContentLen())
             .setMaxTotalHeaderCount(config.getMaxTotalHeaderCount())
+            .setMaxTotalHeaderLen(config.getMaxTotalHeaderLen())
             .setMaxPartCount(config.getMaxPartCount())
             .setMaxNestingDepth(config.getMaxNestingDepth())
             .setCountLineNumbers(config.isCountLineNumbers())
@@ -241,6 +257,7 @@ public final class MimeConfig {
         private int maxHeaderLen;
         private long maxContentLen;
         private int maxTotalHeaderCount;
+        private long maxTotalHeaderLen;
         private int maxPartCount;
         private int maxNestingDepth;
         private boolean countLineNumbers;
@@ -256,6 +273,7 @@ public final class MimeConfig {
             this.maxHeaderLen = 10000;
             this.maxContentLen = -1;
             this.maxTotalHeaderCount = 16384;
+            this.maxTotalHeaderLen = 1024 * 1024;
             this.maxPartCount = 512;
             this.maxNestingDepth = 64;
             this.headlessParsing = null;
@@ -386,6 +404,31 @@ public final class MimeConfig {
             return this;
         }
 
+        /**
+         * Sets the maximum number of bytes a message may spend on header 
fields in
+         * total, across every entity. Parsing will be terminated with a
+         * {@link org.apache.james.mime4j.io.MaxHeaderLengthLimitException} if 
a
+         * message carries more header bytes than this limit. If this 
parameter is
+         * set to a non positive value the total header length check is 
disabled.
+         * <p>
+         * This is to {@link #setMaxHeaderLen(int)} what
+         * {@link #setMaxTotalHeaderCount(int)} is to {@link 
#setMaxHeaderCount(int)}.
+         * A per header bound multiplies by the number of entities: {@code 
Content-Type}
+         * and {@code Content-Disposition} occur once per part, so bounding 
each one
+         * still leaves {@link #setMaxPartCount(int)} times that much. 
Address, group
+         * and parameter lists are retained as one object per item, so only a 
message
+         * wide budget on header bytes bounds the retained graph.
+         * <p>
+         * Default value: <code>1048576</code> (1 MB)
+         *
+         * @param maxTotalHeaderLen
+         *            maximum total header length limit
+         */
+        public Builder setMaxTotalHeaderLen(long maxTotalHeaderLen) {
+            this.maxTotalHeaderLen = maxTotalHeaderLen;
+            return this;
+        }
+
         /**
          * Sets the maximum number of MIME entities (body parts and embedded
          * messages) a message may be made of. Parsing will be terminated with 
a
@@ -471,6 +514,7 @@ public final class MimeConfig {
                     maxHeaderLen,
                     maxContentLen,
                     maxTotalHeaderCount,
+                    maxTotalHeaderLen,
                     maxPartCount,
                     maxNestingDepth,
                     countLineNumbers,
diff --git 
a/core/src/main/java/org/apache/james/mime4j/stream/MimeTokenStream.java 
b/core/src/main/java/org/apache/james/mime4j/stream/MimeTokenStream.java
index 0c8271ef..1bd46c57 100644
--- a/core/src/main/java/org/apache/james/mime4j/stream/MimeTokenStream.java
+++ b/core/src/main/java/org/apache/james/mime4j/stream/MimeTokenStream.java
@@ -31,9 +31,11 @@ import org.apache.james.mime4j.Charsets;
 import org.apache.james.mime4j.MimeException;
 import org.apache.james.mime4j.codec.DecodeMonitor;
 import org.apache.james.mime4j.io.LineNumberInputStream;
+import org.apache.james.mime4j.io.MaxHeaderLengthLimitException;
 import org.apache.james.mime4j.io.MaxHeaderLimitException;
 import org.apache.james.mime4j.io.MaxNestingDepthLimitException;
 import org.apache.james.mime4j.io.MaxPartCountLimitException;
+import org.apache.james.mime4j.util.ByteSequence;
 import org.apache.james.mime4j.util.CharsetUtil;
 
 /**
@@ -93,6 +95,7 @@ public class MimeTokenStream {
     private MimeEntity rootentity;
     private int partCount;
     private int totalHeaderCount;
+    private long totalHeaderLen;
 
     /**
      * Constructs a standard (lax) stream.
@@ -211,6 +214,7 @@ public class MimeTokenStream {
         currentStateMachine = rootentity;
         partCount = 0;
         totalHeaderCount = 0;
+        totalHeaderLen = 0;
         entities.clear();
         entities.add(currentStateMachine);
         state = currentStateMachine.getState();
@@ -388,7 +392,7 @@ public class MimeTokenStream {
             state = currentStateMachine.getState();
             if (state != EntityState.T_END_OF_STREAM) {
                 if (state == EntityState.T_FIELD) {
-                    checkTotalHeaderLimit();
+                    checkTotalHeaderLimits();
                 }
                 return state;
             }
@@ -440,16 +444,43 @@ public class MimeTokenStream {
      * a consumer building a DOM, so only a message wide budget bounds that 
graph
      * independently of the part count.
      * <p>
+     * The same reasoning applies to header bytes: {@link 
MimeConfig#getMaxHeaderLen()}
+     * bounds one field, but {@code Content-Type} and {@code 
Content-Disposition}
+     * occur once per entity and so multiply by the part count.
+     * <p>
      * Counts the fields actually reported to the caller: malformed fields the
      * parser skips are not retained and so do not consume the budget.
      */
-    private void checkTotalHeaderLimit() throws MimeException {
+    private void checkTotalHeaderLimits() throws MimeException {
         int maxTotalHeaderCount = config.getMaxTotalHeaderCount();
         totalHeaderCount++;
         if (maxTotalHeaderCount > 0 && totalHeaderCount > maxTotalHeaderCount) 
{
             throw new MaxHeaderLimitException("Maximum total header limit ("
                     + maxTotalHeaderCount + ") exceeded");
         }
+        long maxTotalHeaderLen = config.getMaxTotalHeaderLen();
+        if (maxTotalHeaderLen > 0) {
+            totalHeaderLen += rawLengthOf(currentStateMachine.getField());
+            if (totalHeaderLen > maxTotalHeaderLen) {
+                throw new MaxHeaderLengthLimitException("Maximum total header 
length limit ("
+                        + maxTotalHeaderLen + ") exceeded");
+            }
+        }
+    }
+
+    /**
+     * Size of a field as it appeared on the wire. Falls back to the rendered 
length
+     * when a field carries no raw bytes, rather than calling {@code 
getSafeRaw()}
+     * which would allocate them just to measure them.
+     */
+    private static int rawLengthOf(Field field) {
+        ByteSequence raw = field.getRaw();
+        if (raw != null) {
+            return raw.length();
+        }
+        String name = field.getName();
+        String body = field.getBody();
+        return (name != null ? name.length() : 0) + (body != null ? 
body.length() : 0) + 4;
     }
 
     /**
diff --git 
a/core/src/test/java/org/apache/james/mime4j/stream/MimeEntityLimitsTest.java 
b/core/src/test/java/org/apache/james/mime4j/stream/MimeEntityLimitsTest.java
index bb6078be..f90e6d32 100644
--- 
a/core/src/test/java/org/apache/james/mime4j/stream/MimeEntityLimitsTest.java
+++ 
b/core/src/test/java/org/apache/james/mime4j/stream/MimeEntityLimitsTest.java
@@ -23,6 +23,7 @@ import java.io.ByteArrayInputStream;
 import java.io.InputStream;
 
 import org.apache.james.mime4j.Charsets;
+import org.apache.james.mime4j.io.MaxHeaderLengthLimitException;
 import org.apache.james.mime4j.io.MaxHeaderLimitException;
 import org.apache.james.mime4j.io.MaxNestingDepthLimitException;
 import org.apache.james.mime4j.io.MaxPartCountLimitException;
@@ -344,15 +345,109 @@ public class MimeEntityLimitsTest {
         }
     }
 
+    /** A multipart whose every part carries one header of {@code bytes} 
octets. */
+    private static InputStream multipartWithFatHeaders(int parts, int bytes) {
+        StringBuilder pad = new StringBuilder("x: ");
+        while (pad.length() < bytes) {
+            pad.append('a');
+        }
+        StringBuilder sb = new StringBuilder();
+        sb.append("Content-Type: multipart/mixed; boundary=b\r\n\r\n");
+        for (int p = 0; p < parts; p++) {
+            sb.append("--b\r\n").append(pad).append("\r\n\r\n");
+        }
+        sb.append("--b--\r\n");
+        return new 
ByteArrayInputStream(sb.toString().getBytes(Charsets.US_ASCII));
+    }
+
+    @Test
+    public void totalHeaderLengthShouldAccumulateAcrossEntities() throws 
Exception {
+        // 20 parts x 1000 octets: no single header is anywhere near 
maxHeaderLen
+        MimeConfig config = MimeConfig.custom()
+                .setMaxLineLen(-1)
+                .setMaxHeaderLen(-1)
+                .setMaxTotalHeaderLen(10000)
+                .build();
+        try {
+            parse(config, multipartWithFatHeaders(20, 1000));
+            Assert.fail("MaxHeaderLengthLimitException expected");
+        } catch (MaxHeaderLengthLimitException expected) {
+            Assert.assertTrue(expected.getMessage(),
+                    expected.getMessage().startsWith("Maximum total header 
length limit (10000)"));
+        }
+    }
+
+    @Test
+    public void totalHeaderLengthShouldAcceptAMessageWithinTheLimit() throws 
Exception {
+        MimeConfig config = MimeConfig.custom()
+                .setMaxLineLen(-1)
+                .setMaxHeaderLen(-1)
+                .setMaxTotalHeaderLen(1024 * 1024)
+                .build();
+        Assert.assertEquals(20, parse(config, multipartWithFatHeaders(20, 
1000)));
+    }
+
+    @Test
+    public void totalHeaderLengthShouldBeDisabledWhenNegative() throws 
Exception {
+        MimeConfig config = MimeConfig.custom()
+                .setMaxLineLen(-1)
+                .setMaxHeaderLen(-1)
+                .setMaxTotalHeaderLen(-1)
+                .build();
+        Assert.assertEquals(20, parse(config, multipartWithFatHeaders(20, 
100000)));
+    }
+
+    @Test
+    public void totalHeaderLengthShouldBeDisabledWhenZero() throws Exception {
+        MimeConfig config = MimeConfig.custom()
+                .setMaxLineLen(-1)
+                .setMaxHeaderLen(-1)
+                .setMaxTotalHeaderLen(0)
+                .build();
+        Assert.assertEquals(20, parse(config, multipartWithFatHeaders(20, 
100000)));
+    }
+
+    @Test
+    public void defaultConfigShouldBoundTheTotalHeaderLength() throws 
Exception {
+        Assert.assertEquals(1024 * 1024, 
MimeConfig.DEFAULT.getMaxTotalHeaderLen());
+        Assert.assertEquals(1024 * 1024, 
MimeConfig.PERMISSIVE.getMaxTotalHeaderLen());
+        try {
+            // 512 parts x 8 KB of headers = 4 MB, no single header over 64 KB
+            parse(MimeConfig.PERMISSIVE, multipartWithFatHeaders(512, 8192));
+            Assert.fail("MaxHeaderLengthLimitException expected");
+        } catch (MaxHeaderLengthLimitException expected) {
+            // expected
+        }
+    }
+
+    @Test
+    public void permissiveConfigShouldBoundASingleHeaderLength() throws 
Exception {
+        Assert.assertEquals(64 * 1024, 
MimeConfig.PERMISSIVE.getMaxHeaderLen());
+        StringBuilder sb = new StringBuilder("x: ");
+        while (sb.length() < 64 * 1024 + 16) {
+            sb.append('a');
+        }
+        sb.append("\r\n\r\nbody\r\n");
+        InputStream in = new 
ByteArrayInputStream(sb.toString().getBytes(Charsets.US_ASCII));
+        try {
+            parse(MimeConfig.PERMISSIVE, in);
+            Assert.fail("MaxHeaderLengthLimitException expected");
+        } catch (MaxHeaderLengthLimitException expected) {
+            // expected
+        }
+    }
+
     @Test
     public void copyShouldCarryTheLimitsOver() {
         MimeConfig config = MimeConfig.copy(MimeConfig.custom()
                 .setMaxPartCount(7)
                 .setMaxNestingDepth(9)
                 .setMaxTotalHeaderCount(11)
+                .setMaxTotalHeaderLen(13)
                 .build()).build();
         Assert.assertEquals(7, config.getMaxPartCount());
         Assert.assertEquals(9, config.getMaxNestingDepth());
         Assert.assertEquals(11, config.getMaxTotalHeaderCount());
+        Assert.assertEquals(13, config.getMaxTotalHeaderLen());
     }
 }
diff --git 
a/dom/src/test/java/org/apache/james/mime4j/dom/LargeMessageParsingTest.java 
b/dom/src/test/java/org/apache/james/mime4j/dom/LargeMessageParsingTest.java
index b3067e16..4a27e326 100644
--- a/dom/src/test/java/org/apache/james/mime4j/dom/LargeMessageParsingTest.java
+++ b/dom/src/test/java/org/apache/james/mime4j/dom/LargeMessageParsingTest.java
@@ -23,6 +23,7 @@ import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 
 import org.apache.james.mime4j.MimeIOException;
+import org.apache.james.mime4j.io.MaxHeaderLengthLimitException;
 import org.apache.james.mime4j.io.MaxHeaderLimitException;
 import org.apache.james.mime4j.message.DefaultMessageBuilder;
 import org.apache.james.mime4j.stream.MimeConfig;
@@ -74,25 +75,26 @@ public class LargeMessageParsingTest {
 
     @Test
     public void 
parsingAMessageWithLongLinesWithPermissiveConfigShouldSucceed() throws 
Exception {
-        ByteArrayOutputStream longLineOutputStream = new 
ByteArrayOutputStream( 1024 * 1024);
-        ByteArrayOutputStream longHeaderOutputStream = new 
ByteArrayOutputStream( 1024 * 1024);
+        ByteArrayOutputStream longLineOutputStream = new 
ByteArrayOutputStream(1024 * 1024);
+        ByteArrayOutputStream longHeaderOutputStream = new 
ByteArrayOutputStream(1024 * 1024);
 
         longHeaderOutputStream.write("header: ".getBytes());
-        // Each header is ~ 500 Ko
-        for (int i = 0; i < 50 * 1024; i++) {
+        // Each header stays just under the permissive per header cap
+        while (longHeaderOutputStream.size() < 
MimeConfig.PERMISSIVE.getMaxHeaderLen() - 32) {
             longHeaderOutputStream.write("0123456789".getBytes());
         }
         longHeaderOutputStream.write("\r\n".getBytes());
 
-        // Each line is ~ 1Mo
+        // Each line is ~ 1Mo: long lines are still unbounded under the 
permissive profile
         for (int i = 0; i < 100 * 1024; i++) {
             longLineOutputStream.write("0123456789".getBytes());
         }
         longLineOutputStream.write("\r\n".getBytes());
 
         ByteArrayOutputStream outputStream = new ByteArrayOutputStream(100 * 
1024 * 1024);
-        // 60 * 0.5 = ~ 30 Mo of headers
-        for (int i = 0; i < 60; i++) {
+        // as many ~64 Ko headers as the total header budget allows
+        long headers = MimeConfig.PERMISSIVE.getMaxTotalHeaderLen() / 
longHeaderOutputStream.size();
+        for (int i = 0; i < headers; i++) {
             outputStream.write(longHeaderOutputStream.toByteArray());
         }
         outputStream.write("\r\n".getBytes());
@@ -105,4 +107,27 @@ public class LargeMessageParsingTest {
         messageBuilder.setMimeEntityConfig(MimeConfig.PERMISSIVE);
         messageBuilder.parseMessage(new 
ByteArrayInputStream(outputStream.toByteArray()));
     }
+
+    @Test
+    public void parsingAnOversizedHeaderWithPermissiveConfigShouldBeRejected() 
throws Exception {
+        // An address, group or parameter list is retained as one object per 
item, so
+        // a single unbounded header amplifies without limit. 500 Ko in one 
field used
+        // to be accepted; MIME4J-269's "denying a single email to use all JVM 
memory"
+        // covers this too.
+        ByteArrayOutputStream outputStream = new ByteArrayOutputStream(1024 * 
1024);
+        outputStream.write("header: ".getBytes());
+        for (int i = 0; i < 50 * 1024; i++) {
+            outputStream.write("0123456789".getBytes());
+        }
+        outputStream.write("\r\n\r\nbody\r\n".getBytes());
+
+        DefaultMessageBuilder messageBuilder = new DefaultMessageBuilder();
+        messageBuilder.setMimeEntityConfig(MimeConfig.PERMISSIVE);
+        try {
+            messageBuilder.parseMessage(new 
ByteArrayInputStream(outputStream.toByteArray()));
+            Assert.fail("MimeIOException expected");
+        } catch (MimeIOException e) {
+            Assert.assertTrue(e.getCause() instanceof 
MaxHeaderLengthLimitException);
+        }
+    }
 }


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

Reply via email to