This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-codec.git
The following commit(s) were added to refs/heads/master by this push:
new 089f9045 Fix quoted-printable line break decoding
089f9045 is described below
commit 089f90450fd1f8acafad3fe626b9e9425aff5a3c
Author: Gary Gregory <[email protected]>
AuthorDate: Sat Sep 19 04:53:18 2026 -0700
Fix quoted-printable line break decoding
Preserve hard CRLF line breaks and unpaired CR/LF bytes. Require the
complete =CRLF sequence for soft breaks and reject incomplete =CR
escapes.
Clarify decoding limitations and compatibility changes, including
QCodec.
Add regression tests covering both encoding modes,
line-break boundaries, and shared QCodec decoding paths.
---
src/changes/changes.xml | 1 +
.../java/org/apache/commons/codec/net/QCodec.java | 7 +++
.../commons/codec/net/QuotedPrintableCodec.java | 33 ++++++++---
.../org/apache/commons/codec/net/QCodecTest.java | 16 ++++++
.../codec/net/QuotedPrintableCodecTest.java | 67 ++++++++++++++++++----
5 files changed, 106 insertions(+), 18 deletions(-)
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index a78da67f..320900e9 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -64,6 +64,7 @@ The <action> type attribute can be add,update,fix,remove.
<action type="fix" dev="ggregory" due-to="Gary Gregory">Restrict Hex
decoding to ASCII hexadecimal characters.</action>
<action type="fix" dev="ggregory" due-to="Gary Gregory">Validate
BinaryCodec input while preserving leading-bit truncation.</action>
<action type="fix" dev="ggregory" due-to="Gary Gregory">Reject oversized
Beider-Morse input before language guessing.</action>
+ <action type="fix" dev="ggregory" due-to="Gary
Gregory">QuotedPrintableCodec decoding now preserves hard CRLF line breaks and,
leniently, unpaired CR and LF bytes instead of discarding them. Soft line
breaks require the full =CRLF sequence; previously accepted =CR without LF now
throws DecoderException. This affects both constructor modes and QCodec, which
shares the decoder.</action>
<!-- ADD -->
<action type="add" dev="ggregory" due-to="Gary Gregory">Add and use
PhoneticEngine.Builder and deprecate old constructors.</action>
<action type="add" dev="ggregory" due-to="Gary Gregory">Add
BeiderMorseEncoder.Builder and deprecate old constructor.</action>
diff --git a/src/main/java/org/apache/commons/codec/net/QCodec.java
b/src/main/java/org/apache/commons/codec/net/QCodec.java
index 91077414..c0b004b9 100644
--- a/src/main/java/org/apache/commons/codec/net/QCodec.java
+++ b/src/main/java/org/apache/commons/codec/net/QCodec.java
@@ -101,6 +101,7 @@ public class QCodec extends RFC1522Codec implements
StringEncoder, StringDecoder
PRINTABLE_CHARS.set('}');
PRINTABLE_CHARS.set('~');
}
+
private static final byte UNDERSCORE = 95;
private boolean encodeBlanks;
@@ -165,6 +166,12 @@ public class QCodec extends RFC1522Codec implements
StringEncoder, StringDecoder
* Decodes a quoted-printable string into its original form. Escaped
characters are converted back to their original
* representation.
*
+ * <p>
+ * Uses {@link QuotedPrintableCodec#decodeQuotedPrintable(byte[])} to
decode the encoded text. Since 1.23.0, unescaped CR and LF bytes in malformed
+ * encoded words are preserved rather than discarded, and {@code =CR}
without a following LF is rejected. This lenient handling does not make such
+ * encoded words valid under RFC 2047.
+ * </p>
+ *
* @param str
* quoted-printable string to convert into its original form.
* @return original string.
diff --git
a/src/main/java/org/apache/commons/codec/net/QuotedPrintableCodec.java
b/src/main/java/org/apache/commons/codec/net/QuotedPrintableCodec.java
index 52561e1a..d3ba3478 100644
--- a/src/main/java/org/apache/commons/codec/net/QuotedPrintableCodec.java
+++ b/src/main/java/org/apache/commons/codec/net/QuotedPrintableCodec.java
@@ -45,7 +45,7 @@ import org.apache.commons.codec.binary.StringUtils;
* Note:
* </p>
* <p>
- * Depending on the selected {@code strict} parameter, this class will
implement a different set of rules of the quoted-printable spec:
+ * Depending on the selected {@code strict} parameter, encoding implements a
different set of rules of the quoted-printable spec:
* </p>
* <ul>
* <li>{@code strict=false}: only rules #1 and #2 are implemented</li>
@@ -54,6 +54,7 @@ import org.apache.commons.codec.binary.StringUtils;
* <p>
* Originally, this class only supported the non-strict mode, but the codec in
this partial form could already be used for certain applications that do not
* require quoted-printable line formatting (rules #3, #4, #5), for instance Q
codec. The strict mode has been added in 1.10.
+ * Decoding is independent of this parameter; see {@link
#decodeQuotedPrintable(byte[])} for its behavior.
* </p>
* <p>
* This class is immutable and thread-safe.
@@ -99,14 +100,26 @@ public class QuotedPrintableCodec implements
BinaryEncoder, BinaryDecoder, Strin
}
/**
- * Decodes an array quoted-printable characters into an array of original
bytes. Escaped characters are converted back to their original representation.
+ * Decodes quoted-printable bytes.
+ *
* <p>
- * This function fully implements the quoted-printable encoding
specification (rule #1 through rule #5) as defined in RFC 1521.
+ * Converts hexadecimal escapes to their original bytes, removes soft line
breaks ({@code =CRLF}), and preserves hard CRLF line breaks.
+ * </p>
+ *
+ * <p>
+ * As a lenient extension for malformed input, unpaired CR and LF bytes
are also preserved. An equals sign followed by CR without LF is rejected.
+ * This method does not perform full MIME validation: for example, it
neither removes trailing whitespace nor handles transport padding after an
+ * equals sign. The {@code strict} constructor parameter affects encoding
only.
+ * </p>
+ *
+ * <p>
+ * Since 1.23.0, unescaped CR and LF bytes are preserved and {@code =CR}
without a following LF is rejected. Earlier versions discarded unescaped
+ * CR and LF bytes and accepted {@code =CR} as a soft line break.
* </p>
*
* @param bytes array of quoted-printable characters.
- * @return array of original bytes.
- * @throws DecoderException Thrown if quoted-printable decoding is
unsuccessful.
+ * @return array of original bytes, or {@code null} if the input is {@code
null}.
+ * @throws DecoderException if an escape is incomplete or invalid,
including a soft line break without the full CRLF pair.
*/
public static final byte[] decodeQuotedPrintable(final byte[] bytes)
throws DecoderException {
if (bytes == null) {
@@ -117,8 +130,12 @@ public class QuotedPrintableCodec implements
BinaryEncoder, BinaryDecoder, Strin
final int b = bytes[i];
if (b == ESCAPE_CHAR) {
try {
- // if the next octet is a CR we have found a soft line
break
+ // rule #5: a soft line break is the escape character
followed by a CRLF sequence;
+ // it is removed entirely from the decoded output
if (bytes[++i] == CR) {
+ if (++i >= bytes.length || bytes[i] != LF) {
+ throw new DecoderException("Invalid
quoted-printable encoding: soft line break must be =CRLF");
+ }
continue;
}
final int u = Utils.digit16(bytes[i]);
@@ -127,8 +144,8 @@ public class QuotedPrintableCodec implements BinaryEncoder,
BinaryDecoder, Strin
} catch (final ArrayIndexOutOfBoundsException e) {
throw new DecoderException("Invalid quoted-printable
encoding", e);
}
- } else if (b != CR && b != LF) {
- // every other octet is appended except for CR & LF
+ } else {
+ // Preserve hard line breaks and, leniently, unpaired CR and
LF bytes.
buffer.write(b);
}
}
diff --git a/src/test/java/org/apache/commons/codec/net/QCodecTest.java
b/src/test/java/org/apache/commons/codec/net/QCodecTest.java
index ea17fade..e4bb7d5f 100644
--- a/src/test/java/org/apache/commons/codec/net/QCodecTest.java
+++ b/src/test/java/org/apache/commons/codec/net/QCodecTest.java
@@ -71,6 +71,22 @@ class QCodecTest {
assertEquals("ABC?DEF", new QCodec().decode("=?UTF-8?Q?ABC=3FDEF?="));
}
+ @Test
+ void testDecodeMalformedLineBreaks() throws Exception {
+ final QCodec codec = new QCodec();
+ // Exercise both shared decoder paths, with and without underscore
replacement.
+ for (final String suffix : new String[] { "", "_" }) {
+ final String decodedSuffix = suffix.isEmpty() ? "" : " ";
+ for (final String lineBreak : new String[] { "\r\n", "\r", "\n" })
{
+ assertEquals("SEC" + lineBreak + "RET" + decodedSuffix,
codec.decode("=?UTF-8?Q?SEC" + lineBreak + "RET" + suffix + "?="));
+ }
+ for (final String encoded : new String[] { "foo=\rbar", "foo=\r",
"foo=\nbar" }) {
+ assertThrows(DecoderException.class, () ->
codec.decode("=?UTF-8?Q?" + suffix + encoded + "?="));
+ }
+ assertEquals("SEC\r\nRET" + decodedSuffix,
codec.decode("=?UTF-8?Q?SEC=0D=0ARET" + suffix + "?="));
+ }
+ }
+
@Test
void testDecodeObjects() throws Exception {
final QCodec qcodec = new QCodec();
diff --git
a/src/test/java/org/apache/commons/codec/net/QuotedPrintableCodecTest.java
b/src/test/java/org/apache/commons/codec/net/QuotedPrintableCodecTest.java
index b6ec30af..e7ace4a3 100644
--- a/src/test/java/org/apache/commons/codec/net/QuotedPrintableCodecTest.java
+++ b/src/test/java/org/apache/commons/codec/net/QuotedPrintableCodecTest.java
@@ -17,16 +17,23 @@
package org.apache.commons.codec.net;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.charset.StandardCharsets;
import java.nio.charset.UnsupportedCharsetException;
+import java.util.stream.Stream;
import org.apache.commons.codec.CharEncoding;
import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.EncoderException;
+import org.apache.commons.codec.binary.StringUtils;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.api.Test;
/**
@@ -38,6 +45,18 @@ class QuotedPrintableCodecTest {
static final int[] RUSSIAN_STUFF_UNICODE = { 0x412, 0x441, 0x435, 0x43C,
0x5F, 0x43F, 0x440, 0x438, 0x432, 0x435, 0x442 };
+ private static Stream<Arguments> softLineBreakBoundaryCases() {
+ // @formatter:off
+ return Stream.of(
+ Arguments.of("=\r\n", ""),
+ Arguments.of("=\r\nA=\r\n", "A"),
+ Arguments.of("A=\r\n=\r\nB", "AB"),
+ Arguments.of("A=\r\n\r\nB", "A\r\nB"),
+ Arguments.of("A\r\n=\r\nB", "A\r\nB"),
+ Arguments.of("A=0D=0AB", "A\r\nB"));
+ // @formatter:on
+ }
+
private String constructString(final int[] unicodeChars) {
final StringBuilder buffer = new StringBuilder();
if (unicodeChars != null) {
@@ -167,24 +186,34 @@ class QuotedPrintableCodecTest {
assertEquals(expected, new QuotedPrintableCodec(true).encode(plain));
}
+ @ParameterizedTest
+ @ValueSource(strings = { "Line one\r\nLine two", "SEC\nRET", "SEC\rRET",
"\r\n", "\r", "\n", "\r\nA\r\n\r\n" })
+ void testHardLineBreakDecode(final String input) throws Exception {
+ final byte[] bytes = StringUtils.getBytesUsAscii(input);
+ assertArrayEquals(bytes,
QuotedPrintableCodec.decodeQuotedPrintable(bytes));
+ for (final boolean strict : new boolean[] { false, true }) {
+ assertEquals(input, new
QuotedPrintableCodec(strict).decode(input));
+ }
+ }
+
@Test
void testInvalidEncoding() {
assertThrows(UnsupportedCharsetException.class, () -> new
QuotedPrintableCodec("NONSENSE"));
}
- @Test
- void testSafeCharEncodeDecode() throws Exception {
- final QuotedPrintableCodec qpcodec = new QuotedPrintableCodec();
- final String plain = "abc123_-.*~!@#$%^&()+{}\"\\;:`,/[]";
- final String encoded = qpcodec.encode(plain);
- assertEquals(plain, encoded, "Safe chars quoted-printable encoding
test");
- assertEquals(plain, qpcodec.decode(encoded), "Safe chars
quoted-printable decoding test");
+ @ParameterizedTest
+ @ValueSource(strings = { "foo=\rbar", "foo=\r", "foo=\nbar", "=\r", "=\n",
"=\r\r\n" })
+ void testInvalidSoftLineBreakDecode(final String input) {
+ assertThrows(DecoderException.class, () ->
QuotedPrintableCodec.decodeQuotedPrintable(StringUtils.getBytesUsAscii(input)));
+ for (final boolean strict : new boolean[] { false, true }) {
+ assertThrows(DecoderException.class, () -> new
QuotedPrintableCodec(strict).decode(input));
+ }
}
@Test
- void testSkipNotEncodedCRLF() throws Exception {
- final String qpdata = "CRLF in an\n encoded text should
be=20=\r\n\rskipped in the\r decoding.";
- final String expected = "CRLF in an encoded text should be skipped in
the decoding.";
+ void testPreserveNotEncodedCRLF() throws Exception {
+ final String qpdata = "CRLF in an\n encoded text should
be=20=\r\n\rpreserved in the\r decoding.";
+ final String expected = "CRLF in an\n encoded text should be
\rpreserved in the\r decoding.";
final QuotedPrintableCodec qpcodec = new QuotedPrintableCodec(true);
assertEquals(expected, qpcodec.decode(qpdata));
@@ -193,6 +222,24 @@ class QuotedPrintableCodecTest {
assertEquals(expected, qpcodec.decode(encoded));
}
+ @Test
+ void testSafeCharEncodeDecode() throws Exception {
+ final QuotedPrintableCodec qpcodec = new QuotedPrintableCodec();
+ final String plain = "abc123_-.*~!@#$%^&()+{}\"\\;:`,/[]";
+ final String encoded = qpcodec.encode(plain);
+ assertEquals(plain, encoded, "Safe chars quoted-printable encoding
test");
+ assertEquals(plain, qpcodec.decode(encoded), "Safe chars
quoted-printable decoding test");
+ }
+
+ @ParameterizedTest
+ @MethodSource("softLineBreakBoundaryCases")
+ void testSoftLineBreakBoundaries(final String input, final String
expected) throws Exception {
+ assertArrayEquals(StringUtils.getBytesUsAscii(expected),
QuotedPrintableCodec.decodeQuotedPrintable(StringUtils.getBytesUsAscii(input)));
+ for (final boolean strict : new boolean[] { false, true }) {
+ assertEquals(expected, new
QuotedPrintableCodec(strict).decode(input));
+ }
+ }
+
@Test
void testSoftLineBreakDecode() throws Exception {
final String qpdata = "If you believe that truth=3Dbeauty, then
surely=20=\r\nmathematics is the most beautiful branch of philosophy.";