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 a18cb891ef57e587a378cd70fdbf924bbda85384 Author: Benoit TELLIER <[email protected]> AuthorDate: Wed Nov 4 15:14:06 2026 +0100 [FIX] Fields: keep a long Content-Disposition parameter within the line limit EncoderUtil#encodeHeaderParameter wrote a parameter as one token or quoted string whatever its length, and folding never breaks a run without whitespace: a long filename or Content-Type parameter gave a header line over 998 octets that DefaultMessageBuilder rejects. A new encodeHeaderParameter(name, value, rfc2231Sections) splits such a parameter into RFC 2231 sections (percent encoded in UTF-8 when not ASCII), which the Content-Disposition parser decodes, and rejects it with an IllegalArgumentException otherwise: no Content-Type parser decodes RFC 2231 sections, so Fields.contentType rejects such a value. Fixes #105 --- .../org/apache/james/mime4j/codec/EncoderUtil.java | 107 +++++++++++++++++++++ .../java/org/apache/james/mime4j/field/Fields.java | 10 +- .../james/mime4j/message/MultipartBuilderTest.java | 50 ++++++++++ 3 files changed, 163 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/org/apache/james/mime4j/codec/EncoderUtil.java b/core/src/main/java/org/apache/james/mime4j/codec/EncoderUtil.java index 8a874e37..9613e717 100644 --- a/core/src/main/java/org/apache/james/mime4j/codec/EncoderUtil.java +++ b/core/src/main/java/org/apache/james/mime4j/codec/EncoderUtil.java @@ -54,6 +54,17 @@ public class EncoderUtil { private static final BitSet TOKEN_CHARS = initChars("()<>@,;:\\\"/[]?="); + // RFC 2231 attribute-char: a token char other than "*", "'" and "%" + private static final BitSet ATTRIBUTE_CHARS = initChars("()<>@,;:\\\"/[]?=*'%"); + + private static final Pattern WHITESPACE = Pattern.compile("[ \t]"); + + /** Longest run without whitespace written as is: it fits, with the folding space and a ";", on a 998 octet line */ + private static final int MAX_PARAMETER_LENGTH = 998 - 2; + + /** Length of the value of an RFC 2231 section written for a parameter that is too long */ + private static final int PARAMETER_SECTION_LENGTH = 60; + private static final BitSet ATEXT_CHARS = initChars("()<>@.,;:\\\"[]"); private static final Pattern QUOTE = Pattern.compile("[\\\\\"]"); @@ -215,6 +226,41 @@ public class EncoderUtil { } name = name.toLowerCase(Locale.US); + return encodeHeaderParameterSection(name, value); + } + + /** + * Encodes the specified strings into a header parameter like + * {@link #encodeHeaderParameter(String, String)}, for a header that is + * written on lines of at most 998 octets (RFC 5322 section 2.1.1). Folding + * only breaks a line at a whitespace: a parameter that holds a run without + * whitespace too long for one line is split into RFC 2231 sections, percent encoded in UTF-8 when the value + * is not printable ASCII, if the header parser of the field decodes them. + * + * @param name + * parameter name. + * @param value + * parameter value. + * @param rfc2231Sections + * whether a parameter too long for one line may be split into + * RFC 2231 sections. + * @return encoded result. + * @throws IllegalArgumentException if the name is invalid, or if the + * parameter is too long for one line and cannot be split. + */ + public static String encodeHeaderParameter(String name, String value, boolean rfc2231Sections) { + String parameter = encodeHeaderParameter(name, value); + if (fitsOnFoldedLines(parameter)) { + return parameter; + } + name = name.toLowerCase(Locale.US); + if (!rfc2231Sections || name.indexOf('*') >= 0) { + throw new IllegalArgumentException("Parameter too long for a header line: " + name); + } + return isPrintableAscii(value) ? encodeSections(name, value) : encodeEncodedSections(name, value); + } + + private static String encodeHeaderParameterSection(String name, String value) { // value := token / quoted-string if (isToken(value)) { return name + "=" + value; @@ -223,6 +269,67 @@ public class EncoderUtil { } } + private static boolean fitsOnFoldedLines(String parameter) { + for (String run : WHITESPACE.split(parameter)) { + if (run.getBytes(Charsets.UTF_8).length > MAX_PARAMETER_LENGTH) { + return false; + } + } + return true; + } + + private static boolean isPrintableAscii(String value) { + for (int idx = 0; idx < value.length(); idx++) { + char ch = value.charAt(idx); + if (ch < 32 || ch >= 127) { + return false; + } + } + return true; + } + + // RFC 2231 section 3: name*0="..."; name*1="..." + private static String encodeSections(String name, String value) { + StringBuilder sb = new StringBuilder(); + int section = 0; + for (int start = 0; start < value.length(); start += PARAMETER_SECTION_LENGTH) { + if (section > 0) { + sb.append("; "); + } + String part = value.substring(start, Math.min(value.length(), start + PARAMETER_SECTION_LENGTH)); + sb.append(encodeHeaderParameterSection(name + "*" + section, part)); + section++; + } + return sb.toString(); + } + + // RFC 2231 sections 3 and 4: name*0*=utf-8''%XX...; name*1*=%XX... + private static String encodeEncodedSections(String name, String value) { + StringBuilder sb = new StringBuilder(); + StringBuilder current = new StringBuilder("utf-8''"); + int section = 0; + for (byte b : value.getBytes(Charsets.UTF_8)) { + int v = b & 0xff; + String encoded = ATTRIBUTE_CHARS.get(v) + ? String.valueOf((char) v) + : "%" + hexDigit(v >>> 4) + hexDigit(v & 0xf); + if (current.length() + encoded.length() > PARAMETER_SECTION_LENGTH) { + appendEncodedSection(sb, name, section++, current); + current.setLength(0); + } + current.append(encoded); + } + appendEncodedSection(sb, name, section, current); + return sb.toString(); + } + + private static void appendEncodedSection(StringBuilder sb, String name, int section, CharSequence value) { + if (section > 0) { + sb.append("; "); + } + sb.append(name).append('*').append(section).append("*=").append(value); + } + /** * Shortcut method that encodes the specified text into an encoded-word if * the text has to be encoded. diff --git a/dom/src/main/java/org/apache/james/mime4j/field/Fields.java b/dom/src/main/java/org/apache/james/mime4j/field/Fields.java index c24b0c1b..3881a98e 100644 --- a/dom/src/main/java/org/apache/james/mime4j/field/Fields.java +++ b/dom/src/main/java/org/apache/james/mime4j/field/Fields.java @@ -103,8 +103,9 @@ public class Fields { StringBuilder sb = new StringBuilder(mimeType); for (Map.Entry<String, String> entry : parameters.entrySet()) { sb.append("; "); + // no Content-Type parser decodes RFC 2231 sections: a value too long for a line is rejected sb.append(EncoderUtil.encodeHeaderParameter(entry.getKey(), - entry.getValue())); + entry.getValue(), false)); } String contentType = sb.toString(); return contentType(contentType); @@ -136,7 +137,8 @@ public class Fields { sb.append("; "); String name = param.getName(); String value = param.getValue(); - sb.append(EncoderUtil.encodeHeaderParameter(name, value != null ? value : "")); + // no Content-Type parser decodes RFC 2231 sections: a value too long for a line is rejected + sb.append(EncoderUtil.encodeHeaderParameter(name, value != null ? value : "", false)); } String contentType = sb.toString(); return contentType(contentType); @@ -214,7 +216,7 @@ public class Fields { for (Map.Entry<String, String> entry : parameters.entrySet()) { sb.append("; "); sb.append(EncoderUtil.encodeHeaderParameter(entry.getKey(), - entry.getValue())); + entry.getValue(), true)); } String contentDisposition = sb.toString(); return contentDisposition(contentDisposition); @@ -246,7 +248,7 @@ public class Fields { sb.append("; "); String name = param.getName(); String value = param.getValue(); - sb.append(EncoderUtil.encodeHeaderParameter(name, value != null ? value : "")); + sb.append(EncoderUtil.encodeHeaderParameter(name, value != null ? value : "", true)); } String contentDisposition = sb.toString(); return contentDisposition(contentDisposition); diff --git a/dom/src/test/java/org/apache/james/mime4j/message/MultipartBuilderTest.java b/dom/src/test/java/org/apache/james/mime4j/message/MultipartBuilderTest.java index baefb160..25de31ef 100644 --- a/dom/src/test/java/org/apache/james/mime4j/message/MultipartBuilderTest.java +++ b/dom/src/test/java/org/apache/james/mime4j/message/MultipartBuilderTest.java @@ -33,9 +33,11 @@ import org.apache.james.mime4j.dom.Header; import org.apache.james.mime4j.dom.Message; import org.apache.james.mime4j.dom.Multipart; import org.apache.james.mime4j.dom.TextBody; +import org.apache.james.mime4j.dom.field.ContentDispositionField; import org.apache.james.mime4j.dom.field.ContentTransferEncodingField; import org.apache.james.mime4j.dom.field.ContentTypeField; import org.apache.james.mime4j.stream.Field; +import org.apache.james.mime4j.stream.NameValuePair; import org.junit.Assert; import org.junit.Test; import org.mockito.Mockito; @@ -239,4 +241,52 @@ public class MultipartBuilderTest { Assert.assertEquals("message/rfc822", part.getMimeType()); Assert.assertEquals("text/plain", embedded.getMimeType()); } + + @Test + public void longContentTypeParameterShouldBeRejected() { + String value = repeat("a", 1100); + MultipartBuilder multipart = MultipartBuilder.create("report") + .addContentTypeParameter(new NameValuePair("report-type", value)); + + Assert.assertThrows(IllegalArgumentException.class, + () -> Message.Builder.of().setBody(multipart.build())); + } + + @Test + public void longFilenameIsWrittenWithinTheLineLimit() throws Exception { + assertLongFilenameRoundTrips(repeat("a", 1100)); + } + + @Test + public void longNonAsciiFilenameIsWrittenWithinTheLineLimit() throws Exception { + assertLongFilenameRoundTrips(repeat("\u00e9", 600)); + } + + private void assertLongFilenameRoundTrips(String filename) throws Exception { + Message message = Message.Builder.of() + .setBody(MultipartBuilder.create("mixed") + .addBodyPart(BodyPartBuilder.create() + .setBody("body", StandardCharsets.UTF_8) + .setContentDisposition("attachment", filename)) + .build()) + .build(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new DefaultMessageWriter().writeMessage(message, out); + + for (String line : new String(out.toByteArray(), StandardCharsets.ISO_8859_1).split("\r\n")) { + Assert.assertTrue(line, line.length() <= 998); + } + Message parsed = new DefaultMessageBuilder().parseMessage(new ByteArrayInputStream(out.toByteArray())); + Entity part = ((Multipart) parsed.getBody()).getBodyParts().get(0); + Assert.assertEquals(filename, + ((ContentDispositionField) part.getHeader().getField("Content-Disposition")).getFilename()); + } + + private static String repeat(String s, int count) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < count; i++) { + sb.append(s); + } + return sb.toString(); + } } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
