Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[2.14]: Fix: Fold long lines during SMTP communication #140

Merged
merged 16 commits into from
Mar 17, 2021
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
91 changes: 78 additions & 13 deletions src/Protocol/Smtp.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

namespace Laminas\Mail\Protocol;

use Generator;
use Laminas\Mail\Headers;

/**
* SMTP implementation of Laminas\Mail\Protocol\AbstractProtocol
*
Expand All @@ -18,6 +21,13 @@ class Smtp extends AbstractProtocol
{
use ProtocolTrait;

/**
* RFC 5322 section-2.2.3 specifies maximum of 998 bytes per line.
* This may not be exceeded.
* @see https://tools.ietf.org/html/rfc5322#section-2.2.3
*/
public const SMTP_LINE_LIMIT = 998;

/**
* The transport method for the socket
*
Expand Down Expand Up @@ -170,6 +180,61 @@ public function setUseCompleteQuit($useCompleteQuit)
return $this->useCompleteQuit = (bool) $useCompleteQuit;
}

/**
* Read $data as lines terminated by "\n"
*
* @param string $data
* @param int $chunkSize
* @return Generator|string[]
* @author Elan Ruusamäe <[email protected]>
*/
private static function chunkedReader(string $data, int $chunkSize = 4096): Generator
glensc marked this conversation as resolved.
Show resolved Hide resolved
{
if (($fp = fopen("php://temp", "r+")) === false) {
throw new Exception\RuntimeException('cannot fopen');
}
if (fwrite($fp, $data) === false) {
throw new Exception\RuntimeException('cannot fwrite');
}
rewind($fp);

$line = null;
while (($buffer = fgets($fp, $chunkSize)) !== false) {
$line .= $buffer;

// This is optimization to avoid calling length() in a loop.
// We need to match a condition that is when:
// 1. maximum was read from fgets, which is $chunkSize-1
// 2. last byte of the buffer is not \n
//
// to access last byte of buffer, we can do
// - $buffer[strlen($buffer)-1]
// and when maximum is read from fgets, then:
// - strlen($buffer) === $chunkSize-1
// - strlen($buffer)-1 === $chunkSize-2
// which means this is also true:
// - $buffer[strlen($buffer)-1] === $buffer[$chunkSize-2]
//
// the null coalesce works, as string offset can never be null
$lastByte = $buffer[$chunkSize - 2] ?? null;
glensc marked this conversation as resolved.
Show resolved Hide resolved

// partial read, continue loop to read again to complete the line
// compare \n first as that's usually false
if ($lastByte !== "\n" && $lastByte !== null) {
continue;
}
glensc marked this conversation as resolved.
Show resolved Hide resolved

yield $line;
$line = null;
}

if ($line !== null) {
yield $line;
}

fclose($fp);
}

/**
* Whether or not send QUIT command
*
Expand Down Expand Up @@ -315,25 +380,25 @@ public function data($data)
$this->_send('DATA');
$this->_expect(354, 120); // Timeout set for 2 minutes as per RFC 2821 4.5.3.2

if (($fp = fopen("php://temp", "r+")) === false) {
throw new Exception\RuntimeException('cannot fopen');
}
if (fwrite($fp, $data) === false) {
throw new Exception\RuntimeException('cannot fwrite');
}
unset($data);
rewind($fp);

// max line length is 998 char + \r\n = 1000
while (($line = stream_get_line($fp, 1000, "\n")) !== false) {
$line = rtrim($line, "\r");
$reader = self::chunkedReader($data);
foreach ($reader as $line) {
$line = rtrim($line, "\r\n");
if (isset($line[0]) && $line[0] === '.') {
// Escape lines prefixed with a '.'
$line = '.' . $line;
}

if (strlen($line) > self::SMTP_LINE_LIMIT) {
// Long lines are "folded" by inserting "<CR><LF><SPACE>"
// https://tools.ietf.org/html/rfc5322#section-2.2.3
// Add "-1" to stay within limits,
// because Headers::FOLDING includes a byte for space character after \r\n
$chunks = chunk_split($line, self::SMTP_LINE_LIMIT - 1, Headers::FOLDING);
$line = substr($chunks, 0, -strlen(Headers::FOLDING));
}

$this->_send($line);
}
fclose($fp);

$this->_send('.');
$this->_expect(250, 600); // Timeout set for 10 minutes as per RFC 2821 4.5.3.2
Expand Down
45 changes: 45 additions & 0 deletions test/Transport/SmtpTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,51 @@ public function testReceivesMailArtifacts(): void
$this->assertStringContainsString("\r\n\r\nThis is only a test.", $data, $data);
}

/**
* Fold long lines during smtp communication in Protocol\Smtp class.
* Test folding of long lines following RFC 5322 section-2.2.3
*
* @see https://github.com/laminas/laminas-mail/pull/140
*/
public function testLongLinesFoldingRFC5322(): void
{
$message = 'The folding logic expects exactly 1 byte after \r\n in folding';
$this->assertEquals("\r\n ", Headers::FOLDING, $message);

$message = $this->getMessage();
// Create buffer of 8192 bytes (PHP_SOCK_CHUNK_SIZE)
$buffer = str_repeat('0123456789abcdef', 512);

$maxLen = SmtpProtocol::SMTP_LINE_LIMIT;
$headerWithLargeValue = $buffer;
$headerWithExactlyMaxLineLength = substr($buffer, 0, $maxLen - strlen('X-Exact-Length: '));
$message->getHeaders()->addHeaders([
'X-Ms-Exchange-Antispam-Messagedata' => $headerWithLargeValue,
'X-Exact-Length' => $headerWithExactlyMaxLineLength,
]);

$this->transport->send($message);
$data = $this->connection->getLog();

$lines = explode("\r\n", $data);
$this->assertCount(28, $lines);

foreach ($lines as $line) {
glensc marked this conversation as resolved.
Show resolved Hide resolved
$this->assertLessThanOrEqual($maxLen, strlen($line), sprintf('Line is too long: ' . $line));
}

$this->assertStringNotContainsString(
$headerWithLargeValue,
$data,
"The original header can't be present if it's wrapped"
);
$this->assertStringContainsString(
$headerWithExactlyMaxLineLength,
$data,
"Header with exact length is not wrapped"
);
}

public function testCanUseAuthenticationExtensionsViaPluginManager(): void
{
$options = new SmtpOptions([
Expand Down