Skip to content

Commit d265a9f

Browse files
committed
smtplib: fix CRAM-MD5 on FIPS-only environments
1 parent 9e5cebd commit d265a9f

File tree

3 files changed

+61
-11
lines changed

3 files changed

+61
-11
lines changed

Lib/smtplib.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import email.utils
4646
import email.message
4747
import email.generator
48+
import functools
4849
import base64
4950
import hmac
5051
import copy
@@ -138,7 +139,7 @@ class SMTPAuthenticationError(SMTPResponseException):
138139
"""Authentication error.
139140
140141
Most probably the server didn't accept the username/password
141-
combination provided.
142+
combination provided or the authentication method is not supported.
142143
"""
143144

144145
def quoteaddr(addrstring):
@@ -177,6 +178,18 @@ def _quote_periods(bindata):
177178
def _fix_eols(data):
178179
return re.sub(r'(?:\r\n|\n|\r(?!\n))', CRLF, data)
179180

181+
182+
# CRAM-MD5 may be supported by the server but not by us
183+
# if HMAC-MD5 is not supported.
184+
@functools.cache
185+
def _have_cram_md5_support():
186+
try:
187+
hmac.new(b'', b'', 'md5').hexdigest()
188+
return True
189+
except ValueError:
190+
return False
191+
192+
180193
try:
181194
import ssl
182195
except ImportError:
@@ -665,8 +678,11 @@ def auth_cram_md5(self, challenge=None):
665678
# CRAM-MD5 does not support initial-response.
666679
if challenge is None:
667680
return None
668-
return self.user + " " + hmac.HMAC(
669-
self.password.encode('ascii'), challenge, 'md5').hexdigest()
681+
if not _have_cram_md5_support():
682+
raise SMTPException("CRAM-MD5 is not supported")
683+
password = self.password.encode('ascii')
684+
authcode = hmac.HMAC(password, challenge, 'md5')
685+
return f"{self.user} {authcode.hexdigest()}"
670686

671687
def auth_plain(self, challenge=None):
672688
""" Authobject to use with PLAIN authentication. Requires self.user and
@@ -718,9 +734,10 @@ def login(self, user, password, *, initial_response_ok=True):
718734
advertised_authlist = self.esmtp_features["auth"].split()
719735

720736
# Authentication methods we can handle in our preferred order:
721-
preferred_auths = ['CRAM-MD5', 'PLAIN', 'LOGIN']
722-
723-
# We try the supported authentications in our preferred order, if
737+
if _have_cram_md5_support():
738+
preferred_auths = ['CRAM-MD5', 'PLAIN', 'LOGIN']
739+
else:
740+
preferred_auths = ['PLAIN', 'LOGIN']
724741
# the server supports them.
725742
authlist = [auth for auth in preferred_auths
726743
if auth in advertised_authlist]

Lib/test/test_smtplib.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -926,11 +926,15 @@ def _auth_cram_md5(self, arg=None):
926926
except ValueError as e:
927927
self.push('535 Splitting response {!r} into user and password '
928928
'failed: {}'.format(logpass, e))
929-
return False
930-
valid_hashed_pass = hmac.HMAC(
931-
sim_auth[1].encode('ascii'),
932-
self._decode_base64(sim_cram_md5_challenge).encode('ascii'),
933-
'md5').hexdigest()
929+
return
930+
931+
pwd = sim_auth[1].encode('ascii')
932+
msg = self._decode_base64(sim_cram_md5_challenge).encode('ascii')
933+
try:
934+
valid_hashed_pass = hmac.HMAC(pwd, msg, 'md5').hexdigest()
935+
except ValueError:
936+
self.push('504 CRAM-MD5 is not supported')
937+
return
934938
self._authenticated(user, hashed_pass == valid_hashed_pass)
935939
# end AUTH related stuff.
936940

@@ -1031,6 +1035,7 @@ def handle_error(self):
10311035
class SMTPSimTests(unittest.TestCase):
10321036

10331037
def setUp(self):
1038+
smtplib._have_cram_md5_support.cache_clear()
10341039
self.thread_key = threading_helper.threading_setup()
10351040
self.real_getfqdn = socket.getfqdn
10361041
socket.getfqdn = mock_socket.getfqdn
@@ -1181,6 +1186,29 @@ def testAUTH_CRAM_MD5(self):
11811186
self.assertEqual(resp, (235, b'Authentication Succeeded'))
11821187
smtp.close()
11831188

1189+
@hashlib_helper.block_algorithm('md5')
1190+
def testAUTH_CRAM_MD5_blocked(self):
1191+
# CRAM-MD5 is the only "known" method by the server,
1192+
# but it is not supported by the client. In particular,
1193+
# no challenge will ever be sent.
1194+
self.serv.add_feature("AUTH CRAM-MD5")
1195+
smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost',
1196+
timeout=support.LOOPBACK_TIMEOUT)
1197+
self.addCleanup(smtp.close)
1198+
msg = re.escape("No suitable authentication method found.")
1199+
with self.assertRaisesRegex(smtplib.SMTPException, msg):
1200+
smtp.login(sim_auth[0], sim_auth[1])
1201+
1202+
@hashlib_helper.block_algorithm('md5')
1203+
def testAUTH_CRAM_MD5_blocked_and_fallback(self):
1204+
# Test that PLAIN is tried after CRAM-MD5 failed
1205+
self.serv.add_feature("AUTH CRAM-MD5 PLAIN")
1206+
smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost',
1207+
timeout=support.LOOPBACK_TIMEOUT)
1208+
self.addCleanup(smtp.close)
1209+
resp = smtp.login(sim_auth[0], sim_auth[1])
1210+
self.assertEqual(resp, (235, b'Authentication Succeeded'))
1211+
11841212
@hashlib_helper.requires_hashdigest('md5', openssl=True)
11851213
def testAUTH_multiple(self):
11861214
# Test that multiple authentication methods are tried.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
:meth:`!SMTP.auth_cram_md5` now raises an :exc:`~smtplib.SMTPException`
2+
instead of a :exc:`ValueError` if Python has been built without MD5 support.
3+
In particular, :class:`~smtplib.SMTP` clients will not attempt to use this
4+
method even if the remote server is assumed to support it. Patch by Bénédikt
5+
Tran.

0 commit comments

Comments
 (0)