Skip to content

Commit a3fe96f

Browse files
committed
Merge branch 'master' into defer-ehlo-resp-155
# Conflicts: # aiosmtpd/smtp.py
2 parents 7555fda + 3600a6c commit a3fe96f

File tree

6 files changed

+227
-76
lines changed

6 files changed

+227
-76
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ diffcov.html
3333
diffcov-*.html
3434
.python-version
3535
.pytype/
36+
~temp*

aiosmtpd/docs/NEWS.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22
NEWS for aiosmtpd
33
===================
44

5+
1.2.4 (2021-01-24)
6+
==================
7+
8+
Added
9+
-----
10+
* Optional (default-disabled) logging of ``AUTH`` interaction -- with severe warnings
11+
12+
Fixed/Improved
13+
--------------
14+
* ``AUTH`` command line now sanitized before logging (Closes #233)
15+
* Remove special handling for lone ``=`` during AUTH;
16+
it is now treated as simple Base64-encoded ``b""``.
17+
This is the correct, strict interpretation of :rfc:`4954` mentions about ``=``
18+
519

620
1.2.4 (aiosmtpd-next)
721
=====================

aiosmtpd/docs/handlers.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ those methods of the handler instance will override the built-in methods.
215215
216216
:boldital:`server` is the instance of the ``SMTP`` class invoking the AUTH hook.
217217
This allows the AUTH hook implementation to invoke facilities such as the
218-
``push()`` and ``_auth_interact()`` methods.
218+
``push()`` and ``challenge_auth()`` methods.
219219

220220
:boldital:`args` is a list of string split from the string after the ``AUTH`` command.
221221
``args[0]`` is always equal to ``MECHANISM``.

aiosmtpd/smtp.py

Lines changed: 115 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
import collections
1010
import asyncio.sslproto as sslproto
1111

12-
from base64 import b64decode
12+
from base64 import b64decode, b64encode
1313
from email._header_value_parser import get_addr_spec, get_angle_addr
1414
from email.errors import HeaderParseError
1515
from public import public
1616
from typing import (
1717
Any,
18+
AnyStr,
1819
Awaitable,
1920
Callable,
2021
Dict,
@@ -30,7 +31,8 @@
3031
# region #### Custom Data Types #######################################################
3132

3233
class _Missing:
33-
pass
34+
def __repr__(self):
35+
return "MISSING"
3436

3537

3638
class _AuthMechAttr(NamedTuple):
@@ -59,7 +61,7 @@ class _DataState(enum.Enum):
5961
"AuthMechanismType",
6062
"MISSING",
6163
] # Will be added to by @public
62-
__version__ = '1.3.0a1'
64+
__version__ = '1.3.0a2'
6365
__ident__ = 'Python SMTP {}'.format(__version__)
6466
log = logging.getLogger('mail.log')
6567

@@ -76,6 +78,19 @@ class _DataState(enum.Enum):
7678
# https://tools.ietf.org/html/rfc3207.html#page-3
7779
ALLOWED_BEFORE_STARTTLS = {"NOOP", "EHLO", "STARTTLS", "QUIT"}
7880

81+
# Auth hiding regexes
82+
CLIENT_AUTH_B = re.compile(
83+
# Matches "AUTH" <mechanism> <whitespace_but_not_\r_nor_\n>
84+
br"(?P<authm>\s*AUTH\s+\S+[^\S\r\n]+)"
85+
# Param to AUTH <mechanism>. We only need to sanitize if param is given, which
86+
# for some mechanisms contain sensitive info. If no param is given, then we
87+
# can skip (match fails)
88+
br"(\S+)"
89+
# Optional bCRLF at end. Why optional? Because we also want to sanitize the
90+
# stripped line. If no bCRLF, then this group will be b""
91+
br"(?P<crlf>(?:\r\n)?)", re.IGNORECASE
92+
)
93+
7994
# endregion
8095

8196

@@ -162,6 +177,23 @@ class TLSSetupException(Exception):
162177
pass
163178

164179

180+
@public
181+
def sanitize(text: bytes) -> bytes:
182+
m = CLIENT_AUTH_B.match(text)
183+
if m:
184+
return m.group("authm") + b"********" + m.group("crlf")
185+
return text
186+
187+
188+
@public
189+
def sanitized_log(func: Callable, msg: AnyStr, *args, **kwargs):
190+
sanitized_args = [
191+
sanitize(a) if isinstance(a, bytes) else a
192+
for a in args
193+
]
194+
func(msg, *sanitized_args, **kwargs)
195+
196+
165197
@public
166198
class SMTP(asyncio.StreamReaderProtocol):
167199
command_size_limit = 512
@@ -404,10 +436,14 @@ def _set_rset_state(self):
404436
"""Reset all state variables except the greeting."""
405437
self._set_post_data_state()
406438

407-
async def push(self, status):
408-
response = bytes(
409-
status + '\r\n', 'utf-8' if self.enable_SMTPUTF8 else 'ascii')
410-
self._writer.write(response)
439+
async def push(self, status: AnyStr):
440+
if isinstance(status, str):
441+
response = bytes(
442+
status, 'utf-8' if self.enable_SMTPUTF8 else 'ascii')
443+
else:
444+
response = status
445+
assert isinstance(response, bytes)
446+
self._writer.write(response + b"\r\n")
411447
log.debug("%r << %r", self.session.peer, response)
412448
await self._writer.drain()
413449

@@ -452,10 +488,10 @@ async def _handle_client(self):
452488
# send error response and read the next command line.
453489
await self.push('500 Command line too long')
454490
continue
455-
log.debug('_handle_client readline: %r', line)
491+
sanitized_log(log.debug, '_handle_client readline: %r', line)
456492
# XXX this rstrip may not completely preserve old behavior.
457493
line = line.rstrip(b'\r\n')
458-
log.info('%r >> %r', self.session.peer, line)
494+
sanitized_log(log.info, '%r >> %r', self.session.peer, line)
459495
if not line:
460496
await self.push('500 Error: bad syntax')
461497
continue
@@ -762,18 +798,44 @@ async def smtp_AUTH(self, arg: str) -> None:
762798
if status is not None: # pragma: no branch
763799
await self.push(status)
764800

765-
async def _auth_interact(self, server_message) -> _TriStateType:
766-
blob: bytes
767-
await self.push(server_message)
801+
async def challenge_auth(
802+
self,
803+
challenge: AnyStr,
804+
encode_to_b64: bool = True,
805+
log_client_response: bool = False,
806+
) -> Union[_Missing, bytes]:
807+
"""
808+
Send challenge during authentication. "334 " will be prefixed, so do NOT
809+
put "334 " at start of server_message.
810+
811+
:param challenge: Challenge to send to client. If str, will be utf8-encoded.
812+
:param encode_to_b64: If true, then perform Base64 encoding on challenge
813+
:param log_client_response: Perform logging of client's response.
814+
WARNING: Might cause leak of sensitive information! Do not turn on
815+
unless _absolutely_ necessary!
816+
:return: Response from client, or MISSING
817+
"""
818+
challenge = (
819+
challenge.encode() if isinstance(challenge, str) else challenge
820+
)
821+
assert isinstance(challenge, bytes)
822+
# Trailing space is MANDATORY even if challenge is empty.
823+
# See:
824+
# - https://tools.ietf.org/html/rfc4954#page-4 ¶ 5
825+
# - https://tools.ietf.org/html/rfc4954#page-13 "continue-req"
826+
challenge = b"334 " + (b64encode(challenge) if encode_to_b64 else challenge)
827+
log.debug("%r << challenge: %r", self.session.peer, challenge)
828+
await self.push(challenge)
768829
line = await self._reader.readline()
769-
blob = line.strip()
770-
# '=' and '*' handling are in accordance with RFC4954
771-
if blob == b"=":
772-
log.debug("%r responded with '='", self.session.peer)
773-
return None
830+
if log_client_response:
831+
warn("AUTH interaction logging is enabled!")
832+
warn("Sensitive information might be leaked!")
833+
log.debug("%r >> %r", self.session.peer, line)
834+
blob: bytes = line.strip()
835+
# '*' handling in accordance with RFC4954
774836
if blob == b"*":
775-
log.warning("%r aborted with '*'", self.session.peer)
776-
await self.push("501 Auth aborted")
837+
log.warning("%r aborted AUTH with '*'", self.session.peer)
838+
await self.push("501 5.7.0 Auth aborted")
777839
return MISSING
778840
try:
779841
decoded_blob = b64decode(blob, validate=True)
@@ -783,6 +845,22 @@ async def _auth_interact(self, server_message) -> _TriStateType:
783845
return MISSING
784846
return decoded_blob
785847

848+
_334_PREFIX = re.compile(r"^334 ")
849+
850+
async def _auth_interact(
851+
self,
852+
server_message: str
853+
) -> Union[_Missing, bytes]: # pragma: nocover
854+
warn(
855+
"_auth_interact will be deprecated in version 2.0. "
856+
"Please use challenge_auth() instead.",
857+
DeprecationWarning
858+
)
859+
return await self.challenge_auth(
860+
challenge=self._334_PREFIX.sub("", server_message),
861+
encode_to_b64=False,
862+
)
863+
786864
# IMPORTANT NOTES FOR THE auth_* METHODS
787865
#
788866
# 1. For internal methods, due to how they are called, we must ignore
@@ -797,64 +875,52 @@ async def _auth_interact(self, server_message) -> _TriStateType:
797875
# - 'identity' is not always username, depending on the auth mecha-
798876
# nism. Might be a session key, a one-time user ID, or any kind of
799877
# object, actually.
800-
# - If the client provides "=" for username during interaction, the
801-
# method MUST return b"" (empty bytes) NOT None, because None has been
802-
# used to indicate error/login failure.
803878
# 3. Auth credentials checking is performed in the auth_* methods because
804879
# more advanced auth mechanism might not return login+password pair
805880
# (see #2 above)
806881

807882
async def auth_PLAIN(self, _, args: List[str]):
808883
login_and_password: _TriStateType
809884
if len(args) == 1:
810-
# Trailing space is MANDATORY
811-
# See https://tools.ietf.org/html/rfc4954#page-4 ¶ 5
812-
login_and_password = await self._auth_interact("334 ")
885+
login_and_password = await self.challenge_auth("")
813886
if login_and_password is MISSING:
814887
return
815-
else:
816-
blob = args[1]
817-
if blob == "=":
818-
login_and_password = None
819-
else:
820-
try:
821-
login_and_password = b64decode(blob, validate=True)
822-
except Exception:
823-
await self.push("501 5.5.2 Can't decode base64")
824-
return
825-
# Decode login data
826-
if login_and_password is None:
827-
login = password = None
828888
else:
829889
try:
830-
_, login, password = login_and_password.split(b"\x00")
831-
except ValueError: # not enough args
832-
await self.push("501 5.5.2 Can't split auth value")
890+
login_and_password = b64decode(args[1].encode(), validate=True)
891+
except Exception:
892+
await self.push("501 5.5.2 Can't decode base64")
833893
return
894+
try:
895+
# login data is "{authz_id}\x00{login_id}\x00{password}"
896+
# authz_id can be null, and currently ignored
897+
# See https://tools.ietf.org/html/rfc4616#page-3
898+
_, login, password = login_and_password.split(b"\x00")
899+
except ValueError: # not enough args
900+
await self.push("501 5.5.2 Can't split auth value")
901+
return
834902
# Verify login data
903+
assert login is not None
904+
assert password is not None
835905
if self._auth_callback("PLAIN", login, password):
836-
if login is None:
837-
login = EMPTYBYTES
838906
return login
839907
else:
840908
return MISSING
841909

842910
async def auth_LOGIN(self, _, args: List[str]):
843911
login: _TriStateType
844-
# 'User Name\x00'
845-
login = await self._auth_interact("334 VXNlciBOYW1lAA==")
912+
login = await self.challenge_auth(b"User Name\x00")
846913
if login is MISSING:
847914
return
848915

849916
password: _TriStateType
850-
# 'Password\x00'
851-
password = await self._auth_interact("334 UGFzc3dvcmQA")
917+
password = await self.challenge_auth(b"Password\x00")
852918
if password is MISSING:
853919
return
854920

921+
assert login is not None
922+
assert password is not None
855923
if self._auth_callback("LOGIN", login, password):
856-
if login is None: # pragma: no branch
857-
login = EMPTYBYTES
858924
return login
859925
else:
860926
return MISSING

aiosmtpd/testing/helpers.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,19 +65,16 @@ def start(plugin):
6565
warnings.filterwarnings('always', category=ResourceWarning)
6666

6767

68-
def assert_auth_success(testcase: TestCase, code, response):
69-
testcase.assertEqual(code, 235)
70-
testcase.assertEqual(response, b"2.7.0 Authentication successful")
68+
def assert_auth_success(testcase: TestCase, *response):
69+
assert response == (235, b"2.7.0 Authentication successful")
7170

7271

73-
def assert_auth_invalid(testcase: TestCase, code, response):
74-
testcase.assertEqual(code, 535)
75-
testcase.assertEqual(response, b'5.7.8 Authentication credentials invalid')
72+
def assert_auth_invalid(testcase: TestCase, *response):
73+
assert response == (535, b"5.7.8 Authentication credentials invalid")
7674

7775

78-
def assert_auth_required(testcase: TestCase, code, response):
79-
testcase.assertEqual(code, 530)
80-
testcase.assertEqual(response, b'5.7.0 Authentication required')
76+
def assert_auth_required(testcase: TestCase, *response):
77+
assert response == (530, b"5.7.0 Authentication required")
8178

8279

8380
SUPPORTED_COMMANDS_TLS: bytes = (

0 commit comments

Comments
 (0)