From 75840e7ba443de901bd12438d5ed076b58d253ba Mon Sep 17 00:00:00 2001 From: Sebastian Karlsen Date: Tue, 4 Aug 2026 11:46:43 +0200 Subject: [PATCH] Fix control characters passing unescaped into RFC 2047 encoded-words The "Q" character class escaped printable punctuation and every 8-bit byte but neither \x00-\x1F nor \x7F, so C0 controls and DEL were written into the header verbatim. RFC 2047 defines encoded-text as printable ASCII other than "?" and SPACE, so any of them makes the encoded-word invalid, and a raw CR or LF in a header is a header injection vector. A literal LF failed in a second way with ext/mbstring. encodeMB() uses "\n" as its own chunk separator and expands it into a fold when assembling the result, so an LF in the value was indistinguishable from a chunk boundary: the value was split into two encoded-words and the byte silently disappeared from the decoded header. Add the two missing ranges, which lets \x7B-\x7E, \x7F and \x80-\xFF collapse into \x7B-\xFF. The class was duplicated verbatim in encodeQP() and encodeMB(), the second copy carrying a "see encodeQP()" comment, so it moves into Mail_mimePart::QP_ESCAPE_REGEXP as 4216044 (#34) did for MAX_CHARSET_LENGTH. tests/headers_with_mbstring.phpt pinned the defect as expected output and is regenerated. Case [31] encodes a Japanese subject to ISO-2022-JP, whose charset-switching escapes are ESC, and one of the JIS X 0208 bytes involved is \x0D: the expectations held ten raw ESC bytes and a bare carriage return inside a Subject header, invisible unless viewed with cat -v. Unlike #33, #34 and #35 this reproduces with ext/mbstring present, so tests/rfc2047_control_chars.phpt needs no --INI-- section. It sweeps all 32 C0 control characters plus DEL across both encodings, checking that the encoded-text holds only printable ASCII and that the byte survives a round trip. It reports 34 failures against the previous code. --- Mail/mimePart.php | 24 ++++++--- tests/headers_with_mbstring.phpt | 4 +- tests/rfc2047_control_chars.phpt | 83 ++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 tests/rfc2047_control_chars.phpt diff --git a/Mail/mimePart.php b/Mail/mimePart.php index afb3221..10d827c 100644 --- a/Mail/mimePart.php +++ b/Mail/mimePart.php @@ -95,6 +95,20 @@ class Mail_mimePart */ const MAX_CHARSET_LENGTH = 48; + /** + * Characters that must be escaped as =XX inside an RFC 2047 "Q" encoded-word. + * + * RFC 2047 restricts encoded-text to printable ASCII other than "?" and SPACE, + * so this covers the C0 controls (\x00-\x1F), DEL and every 8-bit byte, along + * with the printable characters that are not safe inside a phrase. SPACE is + * handled separately: encodeQP() rewrites it as "_", which RFC 2047 permits in + * place of "=20". + * + * @internal + * @var string + */ + const QP_ESCAPE_REGEXP = '/([\x00-\x1F\x22-\x29\x2C\x2E\x3A-\x40\x5B-\x60\x7B-\xFF])/'; + /** * The encoding type of this part * @@ -1190,9 +1204,8 @@ public static function encodeQP($str) // ASCII letters, decimal digits, "!", "*", "+", "-", "/", "=", and "_" // "=", "_", "?" must be encoded - $regexp = '/([\x22-\x29\x2C\x2E\x3A-\x40\x5B-\x60\x7B-\x7E\x80-\xFF])/'; $str = preg_replace_callback( - $regexp, array('Mail_mimePart', 'qpReplaceCallback'), $str + self::QP_ESCAPE_REGEXP, array('Mail_mimePart', 'qpReplaceCallback'), $str ); return str_replace(' ', '_', $str); @@ -1273,9 +1286,6 @@ public static function encodeMB($str, $charset, $encoding, $prefix_len=0, $eol=" } } else { // quoted-printable - // see encodeQP() - $regexp = '/([\x22-\x29\x2C\x2E\x3A-\x40\x5B-\x60\x7B-\x7E\x80-\xFF])/'; - for ($i=0; $i<=$length; $i++) { $char = mb_substr($str, $i, 1, $mb_charset); // RFC recommends underline (instead of =20) in place of the space @@ -1285,7 +1295,9 @@ public static function encodeMB($str, $charset, $encoding, $prefix_len=0, $eol=" $char_len = 1; } else { $char = preg_replace_callback( - $regexp, array('Mail_mimePart', 'qpReplaceCallback'), $char + self::QP_ESCAPE_REGEXP, + array('Mail_mimePart', 'qpReplaceCallback'), + $char ); $char_len = strlen($char); } diff --git a/tests/headers_with_mbstring.phpt b/tests/headers_with_mbstring.phpt index fc5b7a6..52eb46e 100644 --- a/tests/headers_with_mbstring.phpt +++ b/tests/headers_with_mbstring.phpt @@ -149,5 +149,5 @@ foreach ($headers as $header) { [30] Mail-Reply-To: =?UTF-8?B?w7bDpMO8?= [30] Mail-Reply-To: =?UTF-8?Q?=C3=B6=C3=A4=C3=BC?= [31] Subject: =?ISO-2022-JP?B?GyRCLWo7M3l1OSk2SBsoQg==?= -[31] Subject: =?ISO-2022-JP?Q?=24B-j=28B=24B=3B3=28B=24Byu=28B?= - =?ISO-2022-JP?Q?=24B9=29=28B=24B6H=28B?= +[31] Subject: =?ISO-2022-JP?Q?=1B=24B-j=1B=28B=1B=24B=3B3=1B=28B?= + =?ISO-2022-JP?Q?=1B=24Byu=1B=28B=1B=24B9=29=1B=28B=1B=24B6H=1B=28B?= diff --git a/tests/rfc2047_control_chars.phpt b/tests/rfc2047_control_chars.phpt new file mode 100644 index 0000000..283c6a0 --- /dev/null +++ b/tests/rfc2047_control_chars.phpt @@ -0,0 +1,83 @@ +--TEST-- +RFC 2047: control characters must not appear raw inside an encoded-word +--FILE-- + +// +// The "Q" encoder escaped \x22-\x29, \x2C, \x2E, \x3A-\x40, \x5B-\x60, \x7B-\x7E +// and \x80-\xFF, but neither \x00-\x1F (the C0 control characters) nor \x7F +// (DEL). Those were emitted into the header verbatim, which is illegal, and for +// CR or LF is a header injection vector. A raw LF was worse still with +// ext/mbstring: encodeMB() uses "\n" as its own chunk separator, so the byte was +// silently swallowed and turned into a fold. +// +// This reproduces with ext/mbstring present, so no --INI-- section is needed. + +function decode_word($matches) +{ + if ($matches[1] == 'B') { + return base64_decode($matches[2]); + } + + return quoted_printable_decode(str_replace('_', ' ', $matches[2])); +} + +// Adjacent encoded-words split by folding whitespace decode without that whitespace +function decode_words($value) +{ + $value = preg_replace('/\?=\r\n =\?/', '?==?', $value); + + return preg_replace_callback('/=\?[^?]+\?([QB])\?([^?]*)\?=/', 'decode_word', $value); +} + +$failed = 0; + +// Every C0 control character, plus DEL. The "ä" (\xC3\xA4 in UTF-8) is only +// there to make the value non-ASCII: a pure ASCII subject is emitted verbatim +// and never reaches the RFC 2047 encoder at all. +$bytes = range(0x00, 0x1F); +$bytes[] = 0x7F; + +foreach (array('quoted-printable', 'base64') as $encoding) { + foreach ($bytes as $byte) { + $subject = "a\xC3\xA4" . chr($byte) . 'b'; + $encoded = Mail_mimePart::encodeHeader('Subject', $subject, 'UTF-8', $encoding); + + // The encoded-text may only hold printable ASCII other than "?" and SPACE + preg_match_all('/=\?[^?]+\?[QB]\?([^?]*)\?=/', $encoded, $matches); + foreach ($matches[1] as $text) { + if (preg_match('/[^\x21-\x3E\x40-\x7E]/', $text, $m)) { + printf( + 'FAIL [%s 0x%02X]: raw 0x%02X inside encoded-text' . PHP_EOL, + $encoding, $byte, ord($m[0]) + ); + $failed++; + } + } + + // ... and the byte must survive the round trip rather than be dropped + if (decode_words($encoded) !== $subject) { + printf( + 'FAIL [%s 0x%02X]: does not decode back to the subject' . PHP_EOL, + $encoding, $byte + ); + $failed++; + } + } +} + +printf('failures: %d' . PHP_EOL, $failed); + +// Every one of them at once: "a" + "ä" + TAB + LF + CR + NUL + DEL + "b" +foreach (array('quoted-printable', 'base64') as $encoding) { + echo Mail_mimePart::encodeHeader( + 'Subject', "a\xC3\xA4\t\n\r\x00\x7Fb", 'UTF-8', $encoding + ) . PHP_EOL; +} +?> +--EXPECT-- +failures: 0 +=?UTF-8?Q?a=C3=A4=09=0A=0D=00=7Fb?= +=?UTF-8?B?YcOkCQoNAH9i?=