diff options
| author | Jake Howard <git@theorangeone.net> | 2026-04-30 13:59:08 +0100 |
|---|---|---|
| committer | Natalia <124304+nessita@users.noreply.github.com> | 2026-06-03 08:37:26 -0300 |
| commit | df887f50198593a0e5b4638bfddbbd43a30fd276 (patch) | |
| tree | fa6c8e4980ed176ab9cea3b6b7a6077b5f2186dc | |
| parent | 70d36515b9cc71700105a14b275583070d48b689 (diff) | |
Fixed CVE-2026-7666 -- Delayed setting SMTP connection until fully configured.
Thanks Kasper Dupont for the report, and Jacob Walls and Natalia Bidart
for reviews.
| -rw-r--r-- | django/core/mail/backends/smtp.py | 54 | ||||
| -rw-r--r-- | docs/releases/5.2.15.txt | 13 | ||||
| -rw-r--r-- | docs/releases/6.0.6.txt | 13 | ||||
| -rw-r--r-- | tests/mail/test_backends.py | 44 |
4 files changed, 97 insertions, 27 deletions
diff --git a/django/core/mail/backends/smtp.py b/django/core/mail/backends/smtp.py index 84154dd1c0..5a45ee1dff 100644 --- a/django/core/mail/backends/smtp.py +++ b/django/core/mail/backends/smtp.py @@ -45,6 +45,7 @@ class EmailBackend(BaseEmailBackend): super().__init__(**kwargs) self.fail_silently = fail_silently self.connection = None + self._partial_connection = None self._lock = threading.RLock() # RemovedInDjango70Warning. @@ -120,6 +121,11 @@ class EmailBackend(BaseEmailBackend): # Nothing to do if the connection is already open. return False + # If a connection was partially opened before, close it. + if self._partial_connection is not None: + self._close_connection(self._partial_connection) + self._partial_connection = None + # If local_hostname is not specified, socket.getfqdn() gets used. # For performance, we use the cached FQDN for local_hostname. connection_params = {"local_hostname": DNS_NAME.get_fqdn()} @@ -128,39 +134,51 @@ class EmailBackend(BaseEmailBackend): if self.use_ssl: connection_params["context"] = self.ssl_context try: - self.connection = self.connection_class( + self._partial_connection = self.connection_class( self.host, self.port, **connection_params ) # TLS/SSL are mutually exclusive, so only attempt TLS over # non-secure connections. if not self.use_ssl and self.use_tls: - self.connection.starttls(context=self.ssl_context) + self._partial_connection.starttls(context=self.ssl_context) if self.username and self.password: - self.connection.login(self.username, self.password) + self._partial_connection.login(self.username, self.password) + + # Don't set connection until it's fully configured. + self.connection = self._partial_connection + self._partial_connection = None + return True except OSError: if not self.fail_silently: raise + def _close_connection(self, connection): + try: + connection.quit() + except (ssl.SSLError, smtplib.SMTPServerDisconnected): + # This happens when calling quit() on a TLS connection + # sometimes, or when the connection was already disconnected + # by the server. + connection.close() + except smtplib.SMTPException: + if self.fail_silently: + return + raise + def close(self): """Close the connection to the email server.""" - if self.connection is None: - return - try: + if self._partial_connection is not None: try: - self.connection.quit() - except (ssl.SSLError, smtplib.SMTPServerDisconnected): - # This happens when calling quit() on a TLS connection - # sometimes, or when the connection was already disconnected - # by the server. - self.connection.close() - except smtplib.SMTPException: - if self.fail_silently: - return - raise - finally: - self.connection = None + self._close_connection(self._partial_connection) + finally: + self._partial_connection = None + if self.connection is not None: + try: + self._close_connection(self.connection) + finally: + self.connection = None def send_messages(self, email_messages): """ diff --git a/docs/releases/5.2.15.txt b/docs/releases/5.2.15.txt index 3659714a08..14796f8581 100644 --- a/docs/releases/5.2.15.txt +++ b/docs/releases/5.2.15.txt @@ -22,3 +22,16 @@ cookies immediately. This issue has severity "low" according to the :ref:`Django security policy <severity-levels>`. + +CVE-2026-7666: Potential unencrypted email transmission via ``STARTTLS`` in the SMTP backend +============================================================================================ + +When using :setting:`EMAIL_USE_TLS`, a failed ``STARTTLS`` handshake could +leave a partially-initialized connection that would subsequently be reused for +sending email without encryption. This can occur with ``fail_silently=True``, +as used by :func:`~django.core.mail.send_mail` and +:class:`~django.middleware.common.BrokenLinkEmailsMiddleware`, among others. +Connections configured with :setting:`EMAIL_USE_SSL` are not affected. + +This issue has severity "low" according to the :ref:`Django security policy +<severity-levels>`. diff --git a/docs/releases/6.0.6.txt b/docs/releases/6.0.6.txt index dcd63121fc..20ae1db93c 100644 --- a/docs/releases/6.0.6.txt +++ b/docs/releases/6.0.6.txt @@ -24,6 +24,19 @@ cookies immediately. This issue has severity "low" according to the :ref:`Django security policy <severity-levels>`. +CVE-2026-7666: Potential unencrypted email transmission via ``STARTTLS`` in the SMTP backend +============================================================================================ + +When using :setting:`EMAIL_USE_TLS`, a failed ``STARTTLS`` handshake could +leave a partially-initialized connection that would subsequently be reused for +sending email without encryption. This can occur with ``fail_silently=True``, +as used by :func:`~django.core.mail.send_mail` and +:class:`~django.middleware.common.BrokenLinkEmailsMiddleware`, among others. +Connections configured with :setting:`EMAIL_USE_SSL` are not affected. + +This issue has severity "low" according to the :ref:`Django security policy +<severity-levels>`. + Bugfixes ======== diff --git a/tests/mail/test_backends.py b/tests/mail/test_backends.py index 0782b18afe..eee501de70 100644 --- a/tests/mail/test_backends.py +++ b/tests/mail/test_backends.py @@ -795,10 +795,9 @@ class SMTPBackendTests(SharedEmailBackendTests, SMTPBackendTestsBase): backend = self.create_backend( username="not empty username", password="not empty password" ) - with mock.patch("smtplib.SMTP.login") as mock_smtp_login, backend: - # Using backend as context manager opens the connection and - # attempts login. - pass + self.addCleanup(backend.close) + with mock.patch("smtplib.SMTP.login") as mock_smtp_login: + backend.open() mock_smtp_login.assert_called_once_with( "not empty username", "not empty password" ) @@ -810,8 +809,12 @@ class SMTPBackendTests(SharedEmailBackendTests, SMTPBackendTestsBase): backend = self.create_backend() self.assertIsNone(backend.connection) opened = backend.open() + self.assertIsNotNone(backend.connection) + self.assertIsNone(backend._partial_connection) backend.close() self.assertIs(opened, True) + self.assertIsNone(backend.connection) + self.assertIsNone(backend._partial_connection) def test_reopen_connection(self): backend = self.create_backend() @@ -819,6 +822,26 @@ class SMTPBackendTests(SharedEmailBackendTests, SMTPBackendTestsBase): backend.connection = mock.Mock(spec=object()) self.assertIs(backend.open(), False) + def test_reopen_replaces_partial_connection(self): + backend = self.create_backend(username="not empty", password="not empty") + self.addCleanup(backend.close) + + error = "SMTP AUTH extension not supported by server." + with self.assertRaisesMessage(SMTPException, error): + backend.open() + self.assertIsNone(backend.connection) + self.assertIsNotNone(backend._partial_connection) + partial_conn = backend._partial_connection + + with self.assertRaisesMessage(SMTPException, error): + backend.open() + self.assertIsNone(backend.connection) + self.assertIsNotNone(backend._partial_connection) + self.assertNotEqual(backend._partial_connection, partial_conn) + + self.assertIsNone(partial_conn.sock) + self.assertIsNotNone(backend._partial_connection.sock) + # RemovedInDjango70Warning. @override_settings(EMAIL_USE_TLS=True) def test_email_tls_use_settings(self): @@ -915,19 +938,20 @@ class SMTPBackendTests(SharedEmailBackendTests, SMTPBackendTestsBase): def test_email_tls_attempts_starttls(self): backend = self.create_backend(use_tls=True) + self.addCleanup(backend.close) self.assertIs(backend.use_tls, True) with self.assertRaisesMessage( SMTPException, "STARTTLS extension not supported by server." ): - with backend: - pass + backend.open() + self.assertIsNone(backend.connection) def test_email_ssl_attempts_ssl_connection(self): backend = self.create_backend(use_ssl=True) self.assertIs(backend.use_ssl, True) with self.assertRaises(SSLError): - with backend: - pass + backend.open() + self.assertIsNone(backend.connection) def test_connection_timeout_default(self): backend = self.create_backend() @@ -944,10 +968,10 @@ class SMTPBackendTests(SharedEmailBackendTests, SMTPBackendTestsBase): myemailbackend = MyEmailBackend( host=self.smtp_controller.hostname, port=self.smtp_controller.port ) + self.addCleanup(myemailbackend.close) myemailbackend.open() self.assertEqual(myemailbackend.timeout, 42) self.assertEqual(myemailbackend.connection.timeout, 42) - myemailbackend.close() # RemovedInDjango70Warning. @override_settings(EMAIL_TIMEOUT=10) @@ -1158,5 +1182,7 @@ class SMTPBackendStoppedServerTests(SMTPBackendTestsBase): """ with self.assertRaises(ConnectionError): self.backend.open() + self.assertIsNone(self.backend.connection) self.backend.fail_silently = True self.backend.open() + self.assertIsNone(self.backend.connection) |
