File CVE-2024-27305.patch of Package python-aiosmtpd.18391
Index: aiosmtpd-1.2.1/aiosmtpd/smtp.py
===================================================================
--- aiosmtpd-1.2.1.orig/aiosmtpd/smtp.py
+++ aiosmtpd-1.2.1/aiosmtpd/smtp.py
@@ -3,6 +3,7 @@ import socket
import asyncio
import logging
import collections
+import enum
from asyncio import sslproto
from email._header_value_parser import get_addr_spec, get_angle_addr
@@ -12,13 +13,20 @@ from warnings import warn
__version__ = '1.2+'
+
+
+class _DataState(enum.Enum):
+ NOMINAL = enum.auto()
+ TOO_LONG = enum.auto()
+ TOO_MUCH = enum.auto()
__ident__ = 'Python SMTP {}'.format(__version__)
log = logging.getLogger('mail.log')
DATA_SIZE_DEFAULT = 33554432
+EMPTY_BARR = bytearray()
EMPTYBYTES = b''
-NEWLINE = '\n'
+NEWLINE = '\r\n'
MISSING = object()
@@ -65,6 +73,11 @@ class SMTP(asyncio.StreamReaderProtocol)
command_size_limit = 512
command_size_limits = collections.defaultdict(
lambda x=command_size_limit: x)
+ line_length_limit = 1001
+ """Maximum line length according to RFC 5321 s 4.5.3.1.6"""
+ # The number comes from this calculation:
+ # (RFC 5322 s 2.1.1 + RFC 6532 s 3.4) 998 octets + CRLF = 1000 octets
+ # (RFC 5321 s 4.5.3.1.6) 1000 octets + "transparent dot" = 1001 octets
def __init__(self, handler,
*,
@@ -637,43 +650,84 @@ class SMTP(asyncio.StreamReaderProtocol)
return
await self.push('354 End data with <CR><LF>.<CR><LF>')
data = []
+
num_bytes = 0
- size_exceeded = False
+ limit = self.data_size_limit
+ line_fragments = []
+ state = _DataState.NOMINAL
while self.transport is not None: # pragma: nobranch
+ # Since eof_received cancels this coroutine,
+ # readuntil() can never raise asyncio.IncompleteReadError.
try:
- line = await self._reader.readline()
+ # https://datatracker.ietf.org/doc/html/rfc5321#section-2.3.8
+ line = await self._reader.readuntil(b'\r\n')
log.debug('DATA readline: %s', line)
+ assert line.endswith(b'\r\n')
except asyncio.CancelledError:
# The connection got reset during the DATA command.
log.info('Connection lost during DATA')
self._writer.close()
raise
- if line == b'.\r\n':
- if data:
- data[-1] = data[-1].rstrip(b'\r\n')
+ except asyncio.LimitOverrunError as e:
+ # The line exceeds StreamReader's "stream limit".
+ # Delay SMTP Status Code sending until data receive is complete
+ # This seems to be implied in RFC 5321 § 4.2.5
+ if state == _DataState.NOMINAL:
+ # Transition to TOO_LONG only if we haven't gone TOO_MUCH yet
+ state = _DataState.TOO_LONG
+ # Discard data immediately to prevent memory pressure
+ data *= 0
+ # Drain the stream anyways
+ line = await self._reader.read(e.consumed)
+ assert not line.endswith(b'\r\n')
+ # A lone dot in a line signals the end of DATA.
+ if not line_fragments and line == b'.\r\n':
break
num_bytes += len(line)
- if (not size_exceeded and
- self.data_size_limit and
- num_bytes > self.data_size_limit):
- size_exceeded = True
+ if state == _DataState.NOMINAL and limit and num_bytes > limit:
+ # Delay SMTP Status Code sending until data receive is complete
+ # This seems to be implied in RFC 5321 § 4.2.5
+ state = _DataState.TOO_MUCH
+ # Discard data immediately to prevent memory pressure
+ data *= 0
+ line_fragments.append(line)
+ if line.endswith(b'\r\n'):
+ # Record data only if state is "NOMINAL"
+ if state == _DataState.NOMINAL:
+ line = EMPTY_BARR.join(line_fragments)
+ if len(line) > self.line_length_limit:
+ # Theoretically we shouldn't reach this place. But it's always
+ # good to practice DEFENSIVE coding.
+ state = _DataState.TOO_LONG
+ # Discard data immediately to prevent memory pressure
+ data *= 0
+ else:
+ data.append(EMPTY_BARR.join(line_fragments))
+ line_fragments *= 0
+
+ # Day of reckoning! Let's take care of those out-of-nominal situations
+ if state != _DataState.NOMINAL:
+ if state == _DataState.TOO_LONG:
+ await self.push("500 Line too long (see RFC5321 4.5.3.1.6)")
+ elif state == _DataState.TOO_MUCH: # pragma: nobranch
await self.push('552 Error: Too much mail data')
- if not size_exceeded:
- data.append(line)
- if size_exceeded:
self._set_post_data_state()
return
+
+ # If unfinished_line is non-empty, then the connection was closed.
+ assert not line_fragments
+
# Remove extraneous carriage returns and de-transparency
# according to RFC 5321, Section 4.5.2.
- for i in range(len(data)):
- text = data[i]
- if text and text[:1] == b'.':
- data[i] = text[1:]
+ for text in data:
+ if text.startswith(b'.'):
+ del text[0]
content = original_content = EMPTYBYTES.join(data)
+ # Discard data immediately to prevent memory pressure
+ data *= 0
if self._decode_data:
if self.enable_SMTPUTF8:
- content = original_content.decode(
- 'utf-8', errors='surrogateescape')
+ content = original_content.decode('utf-8', errors='surrogateescape')
else:
try:
content = original_content.decode('ascii', errors='strict')
@@ -685,6 +739,7 @@ class SMTP(asyncio.StreamReaderProtocol)
return
self.envelope.content = content
self.envelope.original_content = original_content
+
# Call the new API first if it's implemented.
if hasattr(self.event_handler, 'handle_DATA'):
status = await self._call_handler_hook('DATA')
@@ -694,6 +749,7 @@ class SMTP(asyncio.StreamReaderProtocol)
if hasattr(self.event_handler, 'process_message'):
warn('Use handler.handle_DATA() instead of .process_message()',
DeprecationWarning)
+ assert self.session is not None
args = (self.session.peer, self.envelope.mail_from,
self.envelope.rcpt_tos, self.envelope.content)
if asyncio.iscoroutinefunction(
Index: aiosmtpd-1.2.1/aiosmtpd/tests/test_smtpsmuggling.py
===================================================================
--- /dev/null
+++ aiosmtpd-1.2.1/aiosmtpd/tests/test_smtpsmuggling.py
@@ -0,0 +1,134 @@
+# Copyright 2014-2021 The aiosmtpd Developers
+# SPDX-License-Identifier: Apache-2.0
+
+"""Test SMTP smuggling."""
+
+from email.mime.text import MIMEText
+from smtplib import SMTP, SMTP_SSL
+from typing import Generator, Union
+
+import re
+import socket
+import pytest
+import unittest
+import smtplib
+
+from aiosmtpd.controller import Controller
+
+from aiosmtpd.smtp import SMTP as Server
+from aiosmtpd.smtp import Session as ServerSession
+from aiosmtpd.smtp import Envelope
+
+
+class ReceivingHandler:
+ box = None
+
+ def __init__(self):
+ self.box = []
+
+ async def handle_DATA(self, server, session, envelope):
+ self.box.append(envelope)
+ return '250 OK'
+
+
+def new_data(self, msg):
+ self.putcmd("data")
+
+ (code, repl) = self.getreply()
+ if self.debuglevel > 0:
+ self._print_debug('data:', (code, repl))
+ if code != 354:
+ raise SMTPDataError(code, repl)
+ else:
+ ##### Patching input encoding so we can send raw messages
+ #if isinstance(msg, str):
+ # msg = smtplib._fix_eols(msg).encode('ascii')
+ #q = smtplib._quote_periods(msg)
+ #if q[-2:] != smtplib.bCRLF:
+ # q = q + smtplib.bCRLF
+ #q = q + b"." + smtplib.bCRLF
+ q = msg
+ self.send(q)
+ (code, msg) = self.getreply()
+ if self.debuglevel > 0:
+ self._print_debug('data:', (code, msg))
+ return (code, msg)
+
+
+def return_unchanged(data):
+ return data
+
+
+def orig_data(self, msg):
+ self.putcmd("data")
+ (code, repl) = self.getreply()
+ if self.debuglevel > 0:
+ self._print_debug('data:', (code, repl))
+ if code != 354:
+ raise smtplib.SMTPDataError(code, repl)
+ else:
+ if isinstance(msg, str):
+ msg = _fix_eols(msg).encode('ascii')
+ q = _quote_periods(msg)
+ if q[-2:] != smtplib.bCRLF:
+ q = q + smtplib.bCRLF
+
+ q = q + b"." + smtplib.bCRLF
+ self.send(q)
+ (code, msg) = self.getreply()
+ if self.debuglevel > 0:
+ self._print_debug('data:', (code, msg))
+ return (code, msg)
+
+
+def _fix_eols(data):
+ return re.sub(r'(?:\r\n|\n|\r(?!\n))', smtplib.CRLF, data)
+
+
+def _quote_periods(bindata):
+ return re.sub(br'(?m)^\.', b'..', bindata)
+
+
+def return_unchanged(data):
+ return data
+
+
+class TestSmuggling(unittest.TestCase):
+ def setUp(self):
+ self.handler = ReceivingHandler()
+ controller = Controller(self.handler)
+ controller.start()
+ self.addCleanup(controller.stop)
+ self.address = (controller.hostname, controller.port)
+
+ def test_smtp_smuggling(self):
+ smtplib._fix_eols = return_unchanged
+ smtplib._quote_periods = return_unchanged
+ smtplib.SMTP.data = new_data
+
+ with SMTP(*self.address) as client:
+ sender = "sender@example.com"
+ recipients = ["rcpt1@example.com"]
+ resp = client.helo("example.com")
+ # S250_FQDN
+ assert resp == (250, bytes(socket.getfqdn(), "utf-8"))
+
+ # Trying SMTP smuggling with a fake \n.\r\n end-of-data sequence.
+ message_data = b"""\
+ From: Anne Person <anne@example.com>\r\n\
+ To: Bart Person <bart@example.com>\r\n\
+ Subject: A test\r\n\
+ Message-ID: <ant>\r\n\
+ \r\n\
+ Testing\
+ \n.\r\n\
+ NO SMUGGLING
+ \r\n.\r\n\
+ """
+ results = client.sendmail(sender, recipients, message_data)
+ client.quit()
+ smtplib._fix_eols = _fix_eols
+ smtplib._quote_periods = _quote_periods
+ smtplib.SMTP.data = orig_data
+
+ assert b"NO SMUGGLING" in self.handler.box[0].content
Index: aiosmtpd-1.2.1/aiosmtpd/tests/test_handlers.py
===================================================================
--- aiosmtpd-1.2.1.orig/aiosmtpd/tests/test_handlers.py
+++ aiosmtpd-1.2.1/aiosmtpd/tests/test_handlers.py
@@ -425,7 +425,7 @@ Testing
'Subject: A test',
'X-Peer: ::1',
'',
- 'Testing']).encode('ascii')
+ 'Testing', '']).encode('ascii')
def test_deliver_bytes(self):
with ExitStack() as resources:
Index: aiosmtpd-1.2.1/aiosmtpd/tests/test_smtp.py
===================================================================
--- aiosmtpd-1.2.1.orig/aiosmtpd/tests/test_smtp.py
+++ aiosmtpd-1.2.1/aiosmtpd/tests/test_smtp.py
@@ -159,14 +159,14 @@ class TestProtocol(unittest.TestCase):
def test_honors_mail_delimeters(self):
handler = ReceivingHandler()
- data = b'test\r\nmail\rdelimeters\nsaved'
+ data = b'test\r\nmail\rdelimeters\nsaved\r\n'
protocol = self._get_protocol(handler)
protocol.data_received(BCRLF.join([
b'HELO example.org',
b'MAIL FROM: <anne@example.com>',
b'RCPT TO: <anne@example.com>',
b'DATA',
- data + b'\r\n.',
+ data + b'.',
b'QUIT\r\n'
]))
try:
@@ -902,7 +902,8 @@ Testing
mail = CRLF.join(['Test', '.', 'mail'])
client.sendmail('anne@example.com', ['bart@example.com'], mail)
self.assertEqual(len(handler.box), 1)
- self.assertEqual(handler.box[0].content, 'Test\r\n.\r\nmail')
+ self.assertEqual(handler.box[0].content, mail + CRLF)
+
def test_unexpected_errors(self):
handler = ErroringHandler()