Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions Mail/mimePart.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
Expand Down
4 changes: 2 additions & 2 deletions tests/headers_with_mbstring.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -149,5 +149,5 @@ foreach ($headers as $header) {
[30] Mail-Reply-To: =?UTF-8?B?w7bDpMO8?= <adresse@adresse.de>
[30] Mail-Reply-To: =?UTF-8?Q?=C3=B6=C3=A4=C3=BC?= <adresse@adresse.de>
[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?=
83 changes: 83 additions & 0 deletions tests/rfc2047_control_chars.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
--TEST--
RFC 2047: control characters must not appear raw inside an encoded-word
--FILE--
<?php
include("Mail/mimePart.php");

// RFC 2047: encoded-text = 1*<Any printable ASCII character other than "?" or SPACE>
//
// 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?=
Loading