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-14 ' # 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,38 @@ 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 .authenticate_connection (split_string [0 ], ' ' .join (split_string [1 :]))
935+ else :
936+ super ().process_data (byte_data ) # probably an invalid command, but just let the server handle it
937+
938+ else :
939+ # no need to check length - can only be password; no more literals possible (unless \r\n *in* password)
940+ self .login_literal_length_awaited = 0
941+ self .authenticate_connection (self .login_literal_username , str_data )
942+
916943 # AUTHENTICATE PLAIN can be a two-stage request - handle credentials if they are separate from command
917- if self .awaiting_credentials :
944+ elif self .awaiting_credentials :
918945 self .awaiting_credentials = False
919946 username , password = OAuth2Helper .decode_credentials (str_data )
920947 self .authenticate_connection (username , password , 'authenticate' )
@@ -925,12 +952,29 @@ def process_data(self, byte_data, censor_server_log=False):
925952 super ().process_data (byte_data )
926953 return
927954
928- # we replace the standard LOGIN/AUTHENTICATE commands with OAuth 2.0 authentication
929955 self .authentication_command = match .group ('command' ).lower ()
930956 client_flags = match .group ('flags' )
931957 if self .authentication_command == 'login' :
958+ # string literals are sent as a separate message from the client - note that while length is specified
959+ # we don't actually check this, instead relying on \r\n as usual (technically, as per RFC 9051 (4.3) the
960+ # string literal value can itself contain \r\n, but since the proxy only cares about usernames/passwords
961+ # and it is highly unlikely these will contain \r\n, it is probably safe to avoid this extra complexity)
932962 split_flags = client_flags .split (' ' )
933- if len (split_flags ) > 1 :
963+ literal_match = IMAP_LITERAL_MATCHER .match (split_flags [- 1 ])
964+ if literal_match :
965+ self .authentication_tag = match .group ('tag' )
966+ if len (split_flags ) > 1 :
967+ # email addresses will not contain spaces, but let error checking elsewhere handle that - the
968+ # important thing is any non-literal here *must* be the username (else no need for a literal)
969+ self .login_literal_username = ' ' .join (split_flags [:- 1 ])
970+ self .login_literal_length_awaited = int (literal_match .group ('length' ))
971+ self .censor_next_log = True
972+ if not literal_match .group ('continuation' ):
973+ self .send (b'+ \r \n ' ) # request data (RFC 7888's non-synchronising literals don't require this)
974+
975+ # technically only double-quoted strings are allowed here according to RFC 9051 (4.3), but some clients
976+ # do not obey this - we mandate email addresses as usernames (i.e., no spaces), so can be more flexible
977+ elif len (split_flags ) > 1 :
934978 username = OAuth2Helper .strip_quotes (split_flags [0 ])
935979 password = OAuth2Helper .strip_quotes (' ' .join (split_flags [1 :]))
936980 self .authentication_tag = match .group ('tag' )
0 commit comments