Skip to content

Commit d2a9a0d

Browse files
authored
Merge pull request #80 from simonrob/literals
Add support for IMAP string literals in login command
2 parents 991237f + 9d943db commit d2a9a0d

File tree

2 files changed

+55
-5
lines changed

2 files changed

+55
-5
lines changed

emailproxy.config

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ documentation = Accounts are specified using your email address as the section h
9393

9494
- Office 365 shared mailboxes are supported: add an account entry here using the email address of the shared
9595
mailbox as the account name. When asked to authenticate, log in as the user that access has been delegated to.
96+
Note that Office 365 no-longer supports the `[email protected]/delegated.mailbox` username syntax here.
9697

9798
- It is possible to create Office 365 clients that do not require a secret to be sent. If this is the case for your
9899
setup, delete the `client_secret` line from your account's configuration entry (do not leave the default value).

emailproxy.py

Lines changed: 54 additions & 5 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-16' # 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,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

Comments
 (0)