Skip to content

Commit

Permalink
Merge pull request laminas#140 from glensc/long_header_fields-v2
Browse files Browse the repository at this point in the history
[2.14]: Fix: Fold long lines during SMTP communication
  • Loading branch information
Slamdunk authored Mar 17, 2021
2 parents 3ee285f + 367954e commit 5666cf3
Show file tree
Hide file tree
Showing 2 changed files with 123 additions and 13 deletions.
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
{
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;

// 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;
}

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) {
$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

0 comments on commit 5666cf3

Please sign in to comment.