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

    [FIX] Enforce total header count
---
 .../org/apache/james/mime4j/stream/MimeConfig.java |  44 ++++++++-
 .../james/mime4j/stream/MimeTokenStream.java       |  26 +++++
 .../james/mime4j/stream/MimeEntityLimitsTest.java  | 105 +++++++++++++++++++++
 .../james/mime4j/dom/LargeMessageParsingTest.java  |  34 ++++++-
 4 files changed, 204 insertions(+), 5 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 c11ee6f7..e8a55b7f 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
@@ -28,7 +28,7 @@ public final class MimeConfig {
 
     public static final MimeConfig PERMISSIVE = MimeConfig.custom()
         .setMaxContentLen(100 * 1024 * 1024)
-        .setMaxHeaderCount(-1)
+        .setMaxHeaderCount(4096)
         .setMaxHeaderLen(-1)
         .setMaxLineLen(-1)
         .build();
@@ -43,6 +43,7 @@ public final class MimeConfig {
     private final int maxHeaderCount;
     private final int maxHeaderLen;
     private final long maxContentLen;
+    private final int maxTotalHeaderCount;
     private final int maxPartCount;
     private final int maxNestingDepth;
     private final boolean countLineNumbers;
@@ -55,6 +56,7 @@ public final class MimeConfig {
             int maxHeaderCount,
             int maxHeaderLen,
             long maxContentLen,
+            int maxTotalHeaderCount,
             int maxPartCount,
             int maxNestingDepth,
             boolean countLineNumbers,
@@ -67,6 +69,7 @@ public final class MimeConfig {
         this.maxHeaderCount = maxHeaderCount;
         this.maxHeaderLen = maxHeaderLen;
         this.maxContentLen = maxContentLen;
+        this.maxTotalHeaderCount = maxTotalHeaderCount;
         this.maxPartCount = maxPartCount;
         this.maxNestingDepth = maxNestingDepth;
         this.headlessParsing = headlessParsing;
@@ -137,6 +140,17 @@ public final class MimeConfig {
         return maxContentLen;
     }
 
+    /**
+     * Returns the maximum total header count limit
+     *
+     * @see Builder#setMaxTotalHeaderCount(int)
+     *
+     * @return value of the maximum total header count limit
+     */
+    public int getMaxTotalHeaderCount() {
+        return maxTotalHeaderCount;
+    }
+
     /**
      * Returns the maximum part count limit
      *
@@ -187,6 +201,7 @@ public final class MimeConfig {
                 .append(", maxHeaderCount=").append(maxHeaderCount)
                 .append(", maxHeaderLen=").append(maxHeaderLen)
                 .append(", maxContentLen=").append(maxContentLen)
+                .append(", maxTotalHeaderCount=").append(maxTotalHeaderCount)
                 .append(", maxPartCount=").append(maxPartCount)
                 .append(", maxNestingDepth=").append(maxNestingDepth)
                 .append(", countLineNumbers=").append(countLineNumbers)
@@ -210,6 +225,7 @@ public final class MimeConfig {
             .setMaxHeaderCount(config.getMaxHeaderCount())
             .setMaxHeaderLen(config.getMaxHeaderLen())
             .setMaxContentLen(config.getMaxContentLen())
+            .setMaxTotalHeaderCount(config.getMaxTotalHeaderCount())
             .setMaxPartCount(config.getMaxPartCount())
             .setMaxNestingDepth(config.getMaxNestingDepth())
             .setCountLineNumbers(config.isCountLineNumbers())
@@ -224,6 +240,7 @@ public final class MimeConfig {
         private int maxHeaderCount;
         private int maxHeaderLen;
         private long maxContentLen;
+        private int maxTotalHeaderCount;
         private int maxPartCount;
         private int maxNestingDepth;
         private boolean countLineNumbers;
@@ -238,6 +255,7 @@ public final class MimeConfig {
             this.maxHeaderCount = 1000;
             this.maxHeaderLen = 10000;
             this.maxContentLen = -1;
+            this.maxTotalHeaderCount = 16384;
             this.maxPartCount = 512;
             this.maxNestingDepth = 64;
             this.headlessParsing = null;
@@ -345,6 +363,29 @@ public final class MimeConfig {
             return this;
         }
 
+        /**
+         * Sets the maximum number of header fields a message may contain in
+         * total, across every entity. Parsing will be terminated with a
+         * {@link org.apache.james.mime4j.io.MaxHeaderLimitException} if a 
message
+         * carries more fields than this limit. If this parameter is set to a 
non
+         * positive value the total header count check will be disabled.
+         * <p>
+         * This complements {@link #setMaxHeaderCount(int)}, which is enforced 
per
+         * entity and therefore multiplies by {@link #setMaxPartCount(int)}: a
+         * message may hold few enough fields in every single entity yet still
+         * carry a very large number of them overall. Only a message wide 
budget
+         * bounds the retained header graph independently of the part count.
+         * <p>
+         * Default value: <code>16384</code>
+         *
+         * @param maxTotalHeaderCount
+         *            maximum total header count limit
+         */
+        public Builder setMaxTotalHeaderCount(int maxTotalHeaderCount) {
+            this.maxTotalHeaderCount = maxTotalHeaderCount;
+            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
@@ -429,6 +470,7 @@ public final class MimeConfig {
                     maxHeaderCount,
                     maxHeaderLen,
                     maxContentLen,
+                    maxTotalHeaderCount,
                     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 4fba9d0b..0c8271ef 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,6 +31,7 @@ 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.MaxHeaderLimitException;
 import org.apache.james.mime4j.io.MaxNestingDepthLimitException;
 import org.apache.james.mime4j.io.MaxPartCountLimitException;
 import org.apache.james.mime4j.util.CharsetUtil;
@@ -91,6 +92,7 @@ public class MimeTokenStream {
     private RecursionMode recursionMode = RecursionMode.M_RECURSE;
     private MimeEntity rootentity;
     private int partCount;
+    private int totalHeaderCount;
 
     /**
      * Constructs a standard (lax) stream.
@@ -208,6 +210,7 @@ public class MimeTokenStream {
         rootentity.setRecursionMode(recursionMode);
         currentStateMachine = rootentity;
         partCount = 0;
+        totalHeaderCount = 0;
         entities.clear();
         entities.add(currentStateMachine);
         state = currentStateMachine.getState();
@@ -384,6 +387,9 @@ public class MimeTokenStream {
             }
             state = currentStateMachine.getState();
             if (state != EntityState.T_END_OF_STREAM) {
+                if (state == EntityState.T_FIELD) {
+                    checkTotalHeaderLimit();
+                }
                 return state;
             }
             final EntityStateMachine entityStateMachine = 
entities.removeLast();
@@ -426,6 +432,26 @@ public class MimeTokenStream {
         }
     }
 
+    /**
+     * Enforces the message wide budget on header fields. {@link 
MimeConfig#getMaxHeaderCount()}
+     * is enforced per entity by {@link MimeEntity} and so resets on every 
part,
+     * which lets a message stay under it in every single entity while still
+     * carrying a very large number of fields overall. Every field is retained 
by
+     * a consumer building a DOM, so only a message wide budget bounds that 
graph
+     * independently of 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 {
+        int maxTotalHeaderCount = config.getMaxTotalHeaderCount();
+        totalHeaderCount++;
+        if (maxTotalHeaderCount > 0 && totalHeaderCount > maxTotalHeaderCount) 
{
+            throw new MaxHeaderLimitException("Maximum total header limit ("
+                    + maxTotalHeaderCount + ") exceeded");
+        }
+    }
+
     /**
      * Renders a state as a string suitable for logging.
      * @param state
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 6305e1a6..bb6078be 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.MaxHeaderLimitException;
 import org.apache.james.mime4j.io.MaxNestingDepthLimitException;
 import org.apache.james.mime4j.io.MaxPartCountLimitException;
 import org.junit.Assert;
@@ -241,13 +242,117 @@ public class MimeEntityLimitsTest {
         }
     }
 
+    @Test
+    public void permissiveConfigShouldBoundTheHeaderCount() throws Exception {
+        Assert.assertEquals(4096, MimeConfig.PERMISSIVE.getMaxHeaderCount());
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < 4097; i++) {
+            sb.append("x:\r\n");
+        }
+        sb.append("\r\nbody\r\n");
+        InputStream in = new 
ByteArrayInputStream(sb.toString().getBytes(Charsets.US_ASCII));
+        try {
+            parse(MimeConfig.PERMISSIVE, in);
+            Assert.fail("MaxHeaderLimitException expected");
+        } catch (MaxHeaderLimitException expected) {
+            // expected
+        }
+    }
+
+    /** A multipart of {@code parts} body parts, each carrying {@code headers} 
fields. */
+    private static InputStream multipartWithHeaders(int parts, int headers) {
+        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");
+            for (int h = 0; h < headers; h++) {
+                sb.append("x:\r\n");
+            }
+            sb.append("\r\n");
+        }
+        sb.append("--b--\r\n");
+        return new 
ByteArrayInputStream(sb.toString().getBytes(Charsets.US_ASCII));
+    }
+
+    @Test
+    public void totalHeaderLimitShouldAccumulateAcrossEntities() throws 
Exception {
+        // 5 parts x 3 fields = 15 fields, none of which trips the per entity 
limit
+        MimeConfig config = MimeConfig.custom()
+                .setMaxHeaderCount(1000)
+                .setMaxTotalHeaderCount(10)
+                .build();
+        try {
+            parse(config, multipartWithHeaders(5, 3));
+            Assert.fail("MaxHeaderLimitException expected");
+        } catch (MaxHeaderLimitException expected) {
+            Assert.assertEquals("Maximum total header limit (10) exceeded", 
expected.getMessage());
+        }
+    }
+
+    @Test
+    public void totalHeaderLimitShouldAcceptAMessageAtTheLimit() throws 
Exception {
+        // 5 parts x 3 fields, plus the root message's own Content-Type field
+        MimeConfig config = MimeConfig.custom()
+                .setMaxHeaderCount(1000)
+                .setMaxTotalHeaderCount(5 * 3 + 1)
+                .build();
+        Assert.assertEquals(5, parse(config, multipartWithHeaders(5, 3)));
+    }
+
+    @Test
+    public void totalHeaderLimitShouldCountTheRootMessageFields() throws 
Exception {
+        MimeConfig config = MimeConfig.custom()
+                .setMaxHeaderCount(1000)
+                .setMaxTotalHeaderCount(5 * 3)
+                .build();
+        try {
+            parse(config, multipartWithHeaders(5, 3));
+            Assert.fail("MaxHeaderLimitException expected");
+        } catch (MaxHeaderLimitException expected) {
+            // the root Content-Type is the 16th field
+        }
+    }
+
+    @Test
+    public void totalHeaderLimitShouldBeDisabledWhenNegative() throws 
Exception {
+        MimeConfig config = MimeConfig.custom()
+                .setMaxHeaderCount(-1)
+                .setMaxTotalHeaderCount(-1)
+                .build();
+        Assert.assertEquals(50, parse(config, multipartWithHeaders(50, 100)));
+    }
+
+    @Test
+    public void totalHeaderLimitShouldBeDisabledWhenZero() throws Exception {
+        MimeConfig config = MimeConfig.custom()
+                .setMaxHeaderCount(-1)
+                .setMaxTotalHeaderCount(0)
+                .build();
+        Assert.assertEquals(50, parse(config, multipartWithHeaders(50, 100)));
+    }
+
+    @Test
+    public void defaultConfigShouldBoundTheTotalHeaderCount() throws Exception 
{
+        Assert.assertEquals(16384, 
MimeConfig.DEFAULT.getMaxTotalHeaderCount());
+        Assert.assertEquals(16384, 
MimeConfig.PERMISSIVE.getMaxTotalHeaderCount());
+        try {
+            // 400 parts x 100 fields = 40000 fields, only 100 per entity
+            parse(MimeConfig.PERMISSIVE, multipartWithHeaders(400, 100));
+            Assert.fail("MaxHeaderLimitException expected");
+        } catch (MaxHeaderLimitException expected) {
+            // expected
+        }
+    }
+
     @Test
     public void copyShouldCarryTheLimitsOver() {
         MimeConfig config = MimeConfig.copy(MimeConfig.custom()
                 .setMaxPartCount(7)
                 .setMaxNestingDepth(9)
+                .setMaxTotalHeaderCount(11)
                 .build()).build();
         Assert.assertEquals(7, config.getMaxPartCount());
         Assert.assertEquals(9, config.getMaxNestingDepth());
+        Assert.assertEquals(11, config.getMaxTotalHeaderCount());
     }
 }
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 abde7871..b3067e16 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
@@ -22,8 +22,11 @@ package org.apache.james.mime4j.dom;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 
+import org.apache.james.mime4j.MimeIOException;
+import org.apache.james.mime4j.io.MaxHeaderLimitException;
 import org.apache.james.mime4j.message.DefaultMessageBuilder;
 import org.apache.james.mime4j.stream.MimeConfig;
+import org.junit.Assert;
 import org.junit.Test;
 
 public class LargeMessageParsingTest {
@@ -31,14 +34,14 @@ public class LargeMessageParsingTest {
     @Test
     public void parsingALargeMessageWithPermissiveConfigShouldSucceed() throws 
Exception {
         ByteArrayOutputStream outputStream = new ByteArrayOutputStream(100 * 
1024 * 1024);
-        // 32 * 1.000.000 = ~ 30,5 Mo of headers
-        for (int i = 0; i < 1000000; i++) {
-            outputStream.write(String.format("header: static important 
value\r\n", i, i).getBytes());
+        // as many headers as the permissive profile allows
+        for (int i = 0; i < MimeConfig.PERMISSIVE.getMaxHeaderCount(); i++) {
+            outputStream.write("header: static important 
value\r\n".getBytes());
         }
         outputStream.write("\r\n".getBytes());
         // 38 * 1.600.000 = ~ 58 Mo of body
         for (int i = 0; i < 1600000; i++) {
-            
outputStream.write(String.format("abcdeghijklmnopqrstuvwxyz0123456789\r\n", i, 
i).getBytes());
+            
outputStream.write("abcdeghijklmnopqrstuvwxyz0123456789\r\n".getBytes());
         }
 
         DefaultMessageBuilder messageBuilder = new DefaultMessageBuilder();
@@ -46,6 +49,29 @@ public class LargeMessageParsingTest {
         messageBuilder.parseMessage(new 
ByteArrayInputStream(outputStream.toByteArray()));
     }
 
+    @Test
+    public void parsingAHeaderFloodWithPermissiveConfigShouldBeRejected() 
throws Exception {
+        // A header field costs a handful of bytes on the wire but is retained 
as an
+        // object graph by the DOM, so an unbounded header count lets a small 
message
+        // exhaust the heap. MIME4J-269 introduced the permissive profile to be
+        // "very permissive while still denying a single email to use all JVM
+        // memory"; bounding the header count is part of that second half.
+        ByteArrayOutputStream outputStream = new ByteArrayOutputStream(32 * 
1024 * 1024);
+        for (int i = 0; i < 1000000; i++) {
+            outputStream.write("header: static important 
value\r\n".getBytes());
+        }
+        outputStream.write("\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 MaxHeaderLimitException);
+        }
+    }
+
     @Test
     public void 
parsingAMessageWithLongLinesWithPermissiveConfigShouldSucceed() throws 
Exception {
         ByteArrayOutputStream longLineOutputStream = new 
ByteArrayOutputStream( 1024 * 1024);


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

Reply via email to