summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJake Howard <git@theorangeone.net>2026-04-30 13:59:08 +0100
committerNatalia <124304+nessita@users.noreply.github.com>2026-06-03 08:37:26 -0300
commitdf887f50198593a0e5b4638bfddbbd43a30fd276 (patch)
treefa6c8e4980ed176ab9cea3b6b7a6077b5f2186dc
parent70d36515b9cc71700105a14b275583070d48b689 (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.py54
-rw-r--r--docs/releases/5.2.15.txt13
-rw-r--r--docs/releases/6.0.6.txt13
-rw-r--r--tests/mail/test_backends.py44
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)