]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-136134: smtplib: fix CRAM-MD5 on FIPS-only environments (#136623)
authorBénédikt Tran <10796600+picnixz@users.noreply.github.com>
Fri, 22 Aug 2025 11:45:01 +0000 (13:45 +0200)
committerGitHub <noreply@github.com>
Fri, 22 Aug 2025 11:45:01 +0000 (11:45 +0000)
Lib/smtplib.py
Lib/test/test_smtplib.py
Misc/NEWS.d/next/Library/2025-07-13-13-31-22.gh-issue-136134.mh6VjS.rst [new file with mode: 0644]

index 84d6d858e7dec14966b7ab03c382b5dccb2ac243..b71fee8777e866e3b3a1eb0b33a4f1be5f6621d0 100644 (file)
@@ -177,6 +177,15 @@ def _quote_periods(bindata):
 def _fix_eols(data):
     return  re.sub(r'(?:\r\n|\n|\r(?!\n))', CRLF, data)
 
+
+try:
+    hmac.digest(b'', b'', 'md5')
+except ValueError:
+    _have_cram_md5_support = False
+else:
+    _have_cram_md5_support = True
+
+
 try:
     import ssl
 except ImportError:
@@ -665,8 +674,11 @@ class SMTP:
         # CRAM-MD5 does not support initial-response.
         if challenge is None:
             return None
-        return self.user + " " + hmac.HMAC(
-            self.password.encode('ascii'), challenge, 'md5').hexdigest()
+        if not _have_cram_md5_support:
+            raise SMTPException("CRAM-MD5 is not supported")
+        password = self.password.encode('ascii')
+        authcode = hmac.HMAC(password, challenge, 'md5')
+        return f"{self.user} {authcode.hexdigest()}"
 
     def auth_plain(self, challenge=None):
         """ Authobject to use with PLAIN authentication. Requires self.user and
@@ -718,9 +730,10 @@ class SMTP:
         advertised_authlist = self.esmtp_features["auth"].split()
 
         # Authentication methods we can handle in our preferred order:
-        preferred_auths = ['CRAM-MD5', 'PLAIN', 'LOGIN']
-
-        # We try the supported authentications in our preferred order, if
+        if _have_cram_md5_support:
+            preferred_auths = ['CRAM-MD5', 'PLAIN', 'LOGIN']
+        else:
+            preferred_auths = ['PLAIN', 'LOGIN']
         # the server supports them.
         authlist = [auth for auth in preferred_auths
                     if auth in advertised_authlist]
index 4c9fc14bd43f548d63a509b144d5d05432f69ed0..b8aac8c20202a26eccd5d817a8b696770c5dc7d7 100644 (file)
@@ -17,6 +17,7 @@ import textwrap
 import threading
 
 import unittest
+import unittest.mock as mock
 from test import support, mock_socket
 from test.support import hashlib_helper
 from test.support import socket_helper
@@ -926,11 +927,14 @@ class SimSMTPChannel(smtpd.SMTPChannel):
             except ValueError as e:
                 self.push('535 Splitting response {!r} into user and password '
                           'failed: {}'.format(logpass, e))
-                return False
-            valid_hashed_pass = hmac.HMAC(
-                sim_auth[1].encode('ascii'),
-                self._decode_base64(sim_cram_md5_challenge).encode('ascii'),
-                'md5').hexdigest()
+                return
+            pwd = sim_auth[1].encode('ascii')
+            msg = self._decode_base64(sim_cram_md5_challenge).encode('ascii')
+            try:
+                valid_hashed_pass = hmac.HMAC(pwd, msg, 'md5').hexdigest()
+            except ValueError:
+                self.push('504 CRAM-MD5 is not supported')
+                return
             self._authenticated(user, hashed_pass == valid_hashed_pass)
     # end AUTH related stuff.
 
@@ -1181,6 +1185,39 @@ class SMTPSimTests(unittest.TestCase):
         self.assertEqual(resp, (235, b'Authentication Succeeded'))
         smtp.close()
 
+    @hashlib_helper.block_algorithm('md5')
+    @mock.patch("smtplib._have_cram_md5_support", False)
+    def testAUTH_CRAM_MD5_blocked(self):
+        # CRAM-MD5 is the only "known" method by the server,
+        # but it is not supported by the client. In particular,
+        # no challenge will ever be sent.
+        self.serv.add_feature("AUTH CRAM-MD5")
+        smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost',
+                            timeout=support.LOOPBACK_TIMEOUT)
+        self.addCleanup(smtp.close)
+        msg = re.escape("No suitable authentication method found.")
+        with self.assertRaisesRegex(smtplib.SMTPException, msg):
+            smtp.login(sim_auth[0], sim_auth[1])
+
+    @hashlib_helper.block_algorithm('md5')
+    @mock.patch("smtplib._have_cram_md5_support", False)
+    def testAUTH_CRAM_MD5_blocked_and_fallback(self):
+        # Test that PLAIN is tried after CRAM-MD5 failed
+        self.serv.add_feature("AUTH CRAM-MD5 PLAIN")
+        smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost',
+                            timeout=support.LOOPBACK_TIMEOUT)
+        self.addCleanup(smtp.close)
+        with (
+            mock.patch.object(smtp, "auth_cram_md5") as smtp_auth_cram_md5,
+            mock.patch.object(
+                smtp, "auth_plain", wraps=smtp.auth_plain
+            ) as smtp_auth_plain
+        ):
+            resp = smtp.login(sim_auth[0], sim_auth[1])
+        smtp_auth_plain.assert_called_once()
+        smtp_auth_cram_md5.assert_not_called()
+        self.assertEqual(resp, (235, b'Authentication Succeeded'))
+
     @hashlib_helper.requires_hashdigest('md5', openssl=True)
     def testAUTH_multiple(self):
         # Test that multiple authentication methods are tried.
diff --git a/Misc/NEWS.d/next/Library/2025-07-13-13-31-22.gh-issue-136134.mh6VjS.rst b/Misc/NEWS.d/next/Library/2025-07-13-13-31-22.gh-issue-136134.mh6VjS.rst
new file mode 100644 (file)
index 0000000..f0290be
--- /dev/null
@@ -0,0 +1,5 @@
+:meth:`!SMTP.auth_cram_md5` now raises an :exc:`~smtplib.SMTPException`
+instead of a :exc:`ValueError` if Python has been built without MD5 support.
+In particular, :class:`~smtplib.SMTP` clients will not attempt to use this
+method even if the remote server is assumed to support it. Patch by Bénédikt
+Tran.