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]
