Skip to content

Commit 542686a

Browse files
authored
Merge pull request #140 from glensc/long_header_fields-v2
[2.14]: Fix: Fold long lines during SMTP communication
2 parents 36e7e27 + 74f4242 commit 542686a

File tree

2 files changed

+123
-13
lines changed

2 files changed

+123
-13
lines changed

src/Protocol/Smtp.php

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
namespace Laminas\Mail\Protocol;
1010

11+
use Generator;
12+
use Laminas\Mail\Headers;
13+
1114
/**
1215
* SMTP implementation of Laminas\Mail\Protocol\AbstractProtocol
1316
*
@@ -18,6 +21,13 @@ class Smtp extends AbstractProtocol
1821
{
1922
use ProtocolTrait;
2023

24+
/**
25+
* RFC 5322 section-2.2.3 specifies maximum of 998 bytes per line.
26+
* This may not be exceeded.
27+
* @see https://tools.ietf.org/html/rfc5322#section-2.2.3
28+
*/
29+
public const SMTP_LINE_LIMIT = 998;
30+
2131
/**
2232
* The transport method for the socket
2333
*
@@ -170,6 +180,61 @@ public function setUseCompleteQuit($useCompleteQuit)
170180
return $this->useCompleteQuit = (bool) $useCompleteQuit;
171181
}
172182

183+
/**
184+
* Read $data as lines terminated by "\n"
185+
*
186+
* @param string $data
187+
* @param int $chunkSize
188+
* @return Generator|string[]
189+
* @author Elan Ruusamäe <glen@pld-linux.org>
190+
*/
191+
private static function chunkedReader(string $data, int $chunkSize = 4096): Generator
192+
{
193+
if (($fp = fopen("php://temp", "r+")) === false) {
194+
throw new Exception\RuntimeException('cannot fopen');
195+
}
196+
if (fwrite($fp, $data) === false) {
197+
throw new Exception\RuntimeException('cannot fwrite');
198+
}
199+
rewind($fp);
200+
201+
$line = null;
202+
while (($buffer = fgets($fp, $chunkSize)) !== false) {
203+
$line .= $buffer;
204+
205+
// This is optimization to avoid calling length() in a loop.
206+
// We need to match a condition that is when:
207+
// 1. maximum was read from fgets, which is $chunkSize-1
208+
// 2. last byte of the buffer is not \n
209+
//
210+
// to access last byte of buffer, we can do
211+
// - $buffer[strlen($buffer)-1]
212+
// and when maximum is read from fgets, then:
213+
// - strlen($buffer) === $chunkSize-1
214+
// - strlen($buffer)-1 === $chunkSize-2
215+
// which means this is also true:
216+
// - $buffer[strlen($buffer)-1] === $buffer[$chunkSize-2]
217+
//
218+
// the null coalesce works, as string offset can never be null
219+
$lastByte = $buffer[$chunkSize - 2] ?? null;
220+
221+
// partial read, continue loop to read again to complete the line
222+
// compare \n first as that's usually false
223+
if ($lastByte !== "\n" && $lastByte !== null) {
224+
continue;
225+
}
226+
227+
yield $line;
228+
$line = null;
229+
}
230+
231+
if ($line !== null) {
232+
yield $line;
233+
}
234+
235+
fclose($fp);
236+
}
237+
173238
/**
174239
* Whether or not send QUIT command
175240
*
@@ -315,25 +380,25 @@ public function data($data)
315380
$this->_send('DATA');
316381
$this->_expect(354, 120); // Timeout set for 2 minutes as per RFC 2821 4.5.3.2
317382

318-
if (($fp = fopen("php://temp", "r+")) === false) {
319-
throw new Exception\RuntimeException('cannot fopen');
320-
}
321-
if (fwrite($fp, $data) === false) {
322-
throw new Exception\RuntimeException('cannot fwrite');
323-
}
324-
unset($data);
325-
rewind($fp);
326-
327-
// max line length is 998 char + \r\n = 1000
328-
while (($line = stream_get_line($fp, 1000, "\n")) !== false) {
329-
$line = rtrim($line, "\r");
383+
$reader = self::chunkedReader($data);
384+
foreach ($reader as $line) {
385+
$line = rtrim($line, "\r\n");
330386
if (isset($line[0]) && $line[0] === '.') {
331387
// Escape lines prefixed with a '.'
332388
$line = '.' . $line;
333389
}
390+
391+
if (strlen($line) > self::SMTP_LINE_LIMIT) {
392+
// Long lines are "folded" by inserting "<CR><LF><SPACE>"
393+
// https://tools.ietf.org/html/rfc5322#section-2.2.3
394+
// Add "-1" to stay within limits,
395+
// because Headers::FOLDING includes a byte for space character after \r\n
396+
$chunks = chunk_split($line, self::SMTP_LINE_LIMIT - 1, Headers::FOLDING);
397+
$line = substr($chunks, 0, -strlen(Headers::FOLDING));
398+
}
399+
334400
$this->_send($line);
335401
}
336-
fclose($fp);
337402

338403
$this->_send('.');
339404
$this->_expect(250, 600); // Timeout set for 10 minutes as per RFC 2821 4.5.3.2

test/Transport/SmtpTest.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,51 @@ public function testReceivesMailArtifacts(): void
199199
$this->assertStringContainsString("\r\n\r\nThis is only a test.", $data, $data);
200200
}
201201

202+
/**
203+
* Fold long lines during smtp communication in Protocol\Smtp class.
204+
* Test folding of long lines following RFC 5322 section-2.2.3
205+
*
206+
* @see https://github.com/laminas/laminas-mail/pull/140
207+
*/
208+
public function testLongLinesFoldingRFC5322(): void
209+
{
210+
$message = 'The folding logic expects exactly 1 byte after \r\n in folding';
211+
$this->assertEquals("\r\n ", Headers::FOLDING, $message);
212+
213+
$message = $this->getMessage();
214+
// Create buffer of 8192 bytes (PHP_SOCK_CHUNK_SIZE)
215+
$buffer = str_repeat('0123456789abcdef', 512);
216+
217+
$maxLen = SmtpProtocol::SMTP_LINE_LIMIT;
218+
$headerWithLargeValue = $buffer;
219+
$headerWithExactlyMaxLineLength = substr($buffer, 0, $maxLen - strlen('X-Exact-Length: '));
220+
$message->getHeaders()->addHeaders([
221+
'X-Ms-Exchange-Antispam-Messagedata' => $headerWithLargeValue,
222+
'X-Exact-Length' => $headerWithExactlyMaxLineLength,
223+
]);
224+
225+
$this->transport->send($message);
226+
$data = $this->connection->getLog();
227+
228+
$lines = explode("\r\n", $data);
229+
$this->assertCount(28, $lines);
230+
231+
foreach ($lines as $line) {
232+
$this->assertLessThanOrEqual($maxLen, strlen($line), sprintf('Line is too long: ' . $line));
233+
}
234+
235+
$this->assertStringNotContainsString(
236+
$headerWithLargeValue,
237+
$data,
238+
"The original header can't be present if it's wrapped"
239+
);
240+
$this->assertStringContainsString(
241+
$headerWithExactlyMaxLineLength,
242+
$data,
243+
"Header with exact length is not wrapped"
244+
);
245+
}
246+
202247
public function testCanUseAuthenticationExtensionsViaPluginManager(): void
203248
{
204249
$options = new SmtpOptions([

0 commit comments

Comments
 (0)