99import collections
1010import asyncio .sslproto as sslproto
1111
12- from base64 import b64decode
12+ from base64 import b64decode , b64encode
1313from email ._header_value_parser import get_addr_spec , get_angle_addr
1414from email .errors import HeaderParseError
1515from public import public
1616from typing import (
1717 Any ,
18+ AnyStr ,
1819 Awaitable ,
1920 Callable ,
2021 Dict ,
3031# region #### Custom Data Types #######################################################
3132
3233class _Missing :
33- pass
34+ def __repr__ (self ):
35+ return "MISSING"
3436
3537
3638class _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__ )
6466log = logging .getLogger ('mail.log' )
6567
@@ -76,6 +78,19 @@ class _DataState(enum.Enum):
7678# https://tools.ietf.org/html/rfc3207.html#page-3
7779ALLOWED_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
166198class 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
0 commit comments