44__author__ = 'Simon Robinson'
55__copyright__ = 'Copyright (c) 2022 Simon Robinson'
66__license__ = 'Apache 2.0'
7- __version__ = '2022-10-10 ' # ISO 8601 (YYYY-MM-DD)
7+ __version__ = '2022-10-16 ' # ISO 8601 (YYYY-MM-DD)
88
99import argparse
1010import base64
@@ -127,6 +127,7 @@ class NSObject:
127127IMAP_TAG_PATTERN = r"[!#$&',-\[\]-z|}~]+" # https://ietf.org/rfc/rfc9051.html#name-formal-syntax
128128IMAP_AUTHENTICATION_REQUEST_MATCHER = re .compile (
129129 r'^(?P<tag>%s) (?P<command>(LOGIN|AUTHENTICATE)) (?P<flags>.*)$' % IMAP_TAG_PATTERN , flags = re .IGNORECASE )
130+ IMAP_LITERAL_MATCHER = re .compile (r'^{(?P<length>\d+)(?P<continuation>\+?)}$' )
130131IMAP_CAPABILITY_MATCHER = re .compile (r'^(\* |\* OK \[)CAPABILITY .*$' , flags = re .IGNORECASE ) # note: '* ' and '* OK ['
131132
132133REQUEST_QUEUE = queue .Queue () # requests for authentication
@@ -909,12 +910,39 @@ def __init__(self, connection, socket_map, connection_info, server_connection, p
909910 self .authentication_tag = None
910911 self .authentication_command = None
911912 self .awaiting_credentials = False
913+ self .login_literal_length_awaited = 0
914+ self .login_literal_username = None
912915
913916 def process_data (self , byte_data , censor_server_log = False ):
914917 str_data = byte_data .decode ('utf-8' , 'replace' ).rstrip ('\r \n ' )
915918
919+ # LOGIN data can be sent as quoted text or string literals (https://tools.ietf.org/html/rfc9051#section-4.3)
920+ if self .login_literal_length_awaited > 0 :
921+ if not self .login_literal_username :
922+ split_string = str_data .split (' ' )
923+ literal_match = IMAP_LITERAL_MATCHER .match (split_string [- 1 ])
924+ if literal_match and len (byte_data ) > self .login_literal_length_awaited + 2 :
925+ # could be the username and another literal for password (+2: literal length doesn't include \r\n)
926+ # note: plaintext password could end with a string such as ` {1}` that is a valid literal length
927+ self .login_literal_username = ' ' .join (split_string [:- 1 ]) # handle username space errors elsewhere
928+ self .login_literal_length_awaited = int (literal_match .group ('length' ))
929+ self .censor_next_log = True
930+ if not literal_match .group ('continuation' ):
931+ self .send (b'+ \r \n ' ) # request data (RFC 7888's non-synchronising literals don't require this)
932+ elif len (split_string ) > 1 :
933+ # credentials as a single literal doesn't seem to be valid (RFC 9051), but some clients do this
934+ self .login_literal_length_awaited = 0
935+ self .authenticate_connection (split_string [0 ], ' ' .join (split_string [1 :]))
936+ else :
937+ super ().process_data (byte_data ) # probably an invalid command, but just let the server handle it
938+
939+ else :
940+ # no need to check length - can only be password; no more literals possible (unless \r\n *in* password)
941+ self .login_literal_length_awaited = 0
942+ self .authenticate_connection (self .login_literal_username , str_data )
943+
916944 # AUTHENTICATE PLAIN can be a two-stage request - handle credentials if they are separate from command
917- if self .awaiting_credentials :
945+ elif self .awaiting_credentials :
918946 self .awaiting_credentials = False
919947 username , password = OAuth2Helper .decode_credentials (str_data )
920948 self .authenticate_connection (username , password , 'authenticate' )
@@ -925,12 +953,29 @@ def process_data(self, byte_data, censor_server_log=False):
925953 super ().process_data (byte_data )
926954 return
927955
928- # we replace the standard LOGIN/AUTHENTICATE commands with OAuth 2.0 authentication
929956 self .authentication_command = match .group ('command' ).lower ()
930957 client_flags = match .group ('flags' )
931958 if self .authentication_command == 'login' :
959+ # string literals are sent as a separate message from the client - note that while length is specified
960+ # we don't actually check this, instead relying on \r\n as usual (technically, as per RFC 9051 (4.3) the
961+ # string literal value can itself contain \r\n, but since the proxy only cares about usernames/passwords
962+ # and it is highly unlikely these will contain \r\n, it is probably safe to avoid this extra complexity)
932963 split_flags = client_flags .split (' ' )
933- if len (split_flags ) > 1 :
964+ literal_match = IMAP_LITERAL_MATCHER .match (split_flags [- 1 ])
965+ if literal_match :
966+ self .authentication_tag = match .group ('tag' )
967+ if len (split_flags ) > 1 :
968+ # email addresses will not contain spaces, but let error checking elsewhere handle that - the
969+ # important thing is any non-literal here *must* be the username (else no need for a literal)
970+ self .login_literal_username = ' ' .join (split_flags [:- 1 ])
971+ self .login_literal_length_awaited = int (literal_match .group ('length' ))
972+ self .censor_next_log = True
973+ if not literal_match .group ('continuation' ):
974+ self .send (b'+ \r \n ' ) # request data (RFC 7888's non-synchronising literals don't require this)
975+
976+ # technically only double-quoted strings are allowed here according to RFC 9051 (4.3), but some clients
977+ # do not obey this - we mandate email addresses as usernames (i.e., no spaces), so can be more flexible
978+ elif len (split_flags ) > 1 :
934979 username = OAuth2Helper .strip_quotes (split_flags [0 ])
935980 password = OAuth2Helper .strip_quotes (' ' .join (split_flags [1 :]))
936981 self .authentication_tag = match .group ('tag' )
@@ -1276,11 +1321,15 @@ def process_data(self, byte_data):
12761321 # as with SMTP, but all well-known servers provide a non-STARTTLS variant, so left unimplemented for now
12771322 str_response = byte_data .decode ('utf-8' , 'replace' ).rstrip ('\r \n ' )
12781323
1279- # if authentication succeeds, remove our proxy from the client and ignore all further communication
1324+ # if authentication succeeds (or fails) , remove our proxy from the client and ignore all further communication
12801325 # don't use a regex here as the tag must match exactly; RFC 3501 specifies uppercase 'OK', so startswith is fine
12811326 if str_response .startswith ('%s OK' % self .client_connection .authentication_tag ):
12821327 Log .info (self .info_string (), '[ Successfully authenticated IMAP connection - removing proxy ]' )
12831328 self .client_connection .authenticated = True
1329+ elif str_response .startswith ('%s NO' % self .client_connection .authentication_tag ):
1330+ super ().process_data (byte_data ) # an error occurred - just send to the client and exit
1331+ self .close ()
1332+ return
12841333
12851334 # intercept pre-auth CAPABILITY response to advertise only AUTH=PLAIN (+SASL-IR) and re-enable LOGIN if required
12861335 if IMAP_CAPABILITY_MATCHER .match (str_response ):
0 commit comments