Skip to content

Commit 652c6e8

Browse files
committed
Add support for string literals in IMAP LOGIN command
1 parent 991237f commit 652c6e8

File tree

1 file changed

+48
-4
lines changed

1 file changed

+48
-4
lines changed

emailproxy.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
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

99
import argparse
1010
import base64
@@ -127,6 +127,7 @@ class NSObject:
127127
IMAP_TAG_PATTERN = r"[!#$&',-\[\]-z|}~]+" # https://ietf.org/rfc/rfc9051.html#name-formal-syntax
128128
IMAP_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>\+?)}$')
130131
IMAP_CAPABILITY_MATCHER = re.compile(r'^(\* |\* OK \[)CAPABILITY .*$', flags=re.IGNORECASE) # note: '* ' and '* OK ['
131132

132133
REQUEST_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

Comments
 (0)