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]
