티스토리 수익 글 보기

티스토리 수익 글 보기

[6.0.x] Fixed CVE-2026-7666 — Delayed setting SMTP connection until … · django/django@625a670 · GitHub
Skip to content

Commit 625a670

Browse files
committed
[6.0.x] 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. Backport of df887f5 from main.
1 parent c807d9c commit 625a670

4 files changed

Lines changed: 96 additions & 25 deletions

File tree

django/core/mail/backends/smtp.py

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def __init__(
5252
"one of those settings to True."
5353
)
5454
self.connection = None
55+
self._partial_connection = None
5556
self._lock = threading.RLock()
5657

5758
@property
@@ -77,6 +78,11 @@ def open(self):
7778
# Nothing to do if the connection is already open.
7879
return False
7980

81+
# If a connection was partially opened before, close it.
82+
if self._partial_connection is not None:
83+
self._close_connection(self._partial_connection)
84+
self._partial_connection = None
85+
8086
# If local_hostname is not specified, socket.getfqdn() gets used.
8187
# For performance, we use the cached FQDN for local_hostname.
8288
connection_params = {"local_hostname": DNS_NAME.get_fqdn()}
@@ -85,39 +91,51 @@ def open(self):
8591
if self.use_ssl:
8692
connection_params["context"] = self.ssl_context
8793
try:
88-
self.connection = self.connection_class(
94+
self._partial_connection = self.connection_class(
8995
self.host, self.port, **connection_params
9096
)
9197

9298
# TLS/SSL are mutually exclusive, so only attempt TLS over
9399
# non-secure connections.
94100
if not self.use_ssl and self.use_tls:
95-
self.connection.starttls(context=self.ssl_context)
101+
self._partial_connection.starttls(context=self.ssl_context)
96102
if self.username and self.password:
97-
self.connection.login(self.username, self.password)
103+
self._partial_connection.login(self.username, self.password)
104+
105+
# Don't set connection until it's fully configured.
106+
self.connection = self._partial_connection
107+
self._partial_connection = None
108+
98109
return True
99110
except OSError:
100111
if not self.fail_silently:
101112
raise
102113

114+
def _close_connection(self, connection):
115+
try:
116+
connection.quit()
117+
except (ssl.SSLError, smtplib.SMTPServerDisconnected):
118+
# This happens when calling quit() on a TLS connection
119+
# sometimes, or when the connection was already disconnected
120+
# by the server.
121+
connection.close()
122+
except smtplib.SMTPException:
123+
if self.fail_silently:
124+
return
125+
raise
126+
103127
def close(self):
104128
"""Close the connection to the email server."""
105-
if self.connection is None:
106-
return
107-
try:
129+
if self._partial_connection is not None:
108130
try:
109-
self.connection.quit()
110-
except (ssl.SSLError, smtplib.SMTPServerDisconnected):
111-
# This happens when calling quit() on a TLS connection
112-
# sometimes, or when the connection was already disconnected
113-
# by the server.
114-
self.connection.close()
115-
except smtplib.SMTPException:
116-
if self.fail_silently:
117-
return
118-
raise
119-
finally:
120-
self.connection = None
131+
self._close_connection(self._partial_connection)
132+
finally:
133+
self._partial_connection = None
134+
if self.connection is not None:
135+
try:
136+
self._close_connection(self.connection)
137+
finally:
138+
self.connection = None
121139

122140
def send_messages(self, email_messages):
123141
"""

docs/releases/5.2.15.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,16 @@ cookies immediately.
2222

2323
This issue has severity "low" according to the :ref:`Django security policy
2424
<severity-levels>`.
25+
26+
CVE-2026-7666: Potential unencrypted email transmission via ``STARTTLS`` in the SMTP backend
27+
============================================================================================
28+
29+
When using :setting:`EMAIL_USE_TLS`, a failed ``STARTTLS`` handshake could
30+
leave a partially-initialized connection that would subsequently be reused for
31+
sending email without encryption. This can occur with ``fail_silently=True``,
32+
as used by :func:`~django.core.mail.send_mail` and
33+
:class:`~django.middleware.common.BrokenLinkEmailsMiddleware`, among others.
34+
Connections configured with :setting:`EMAIL_USE_SSL` are not affected.
35+
36+
This issue has severity "low" according to the :ref:`Django security policy
37+
<severity-levels>`.

docs/releases/6.0.6.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@ cookies immediately.
2424
This issue has severity "low" according to the :ref:`Django security policy
2525
<severity-levels>`.
2626

27+
CVE-2026-7666: Potential unencrypted email transmission via ``STARTTLS`` in the SMTP backend
28+
============================================================================================
29+
30+
When using :setting:`EMAIL_USE_TLS`, a failed ``STARTTLS`` handshake could
31+
leave a partially-initialized connection that would subsequently be reused for
32+
sending email without encryption. This can occur with ``fail_silently=True``,
33+
as used by :func:`~django.core.mail.send_mail` and
34+
:class:`~django.middleware.common.BrokenLinkEmailsMiddleware`, among others.
35+
Connections configured with :setting:`EMAIL_USE_SSL` are not affected.
36+
37+
This issue has severity "low" according to the :ref:`Django security policy
38+
<severity-levels>`.
39+
2740
Bugfixes
2841
========
2942

tests/mail/tests.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2859,15 +2859,39 @@ def test_server_open(self):
28592859
backend = smtp.EmailBackend(username="", password="")
28602860
self.assertIsNone(backend.connection)
28612861
opened = backend.open()
2862+
self.assertIsNotNone(backend.connection)
2863+
self.assertIsNone(backend._partial_connection)
28622864
backend.close()
28632865
self.assertIs(opened, True)
2866+
self.assertIsNone(backend.connection)
2867+
self.assertIsNone(backend._partial_connection)
28642868

28652869
def test_reopen_connection(self):
28662870
backend = smtp.EmailBackend()
28672871
# Simulate an already open connection.
28682872
backend.connection = mock.Mock(spec=object())
28692873
self.assertIs(backend.open(), False)
28702874

2875+
def test_reopen_replaces_partial_connection(self):
2876+
backend = smtp.EmailBackend(username="not empty", password="not empty")
2877+
self.addCleanup(backend.close)
2878+
2879+
error = "SMTP AUTH extension not supported by server."
2880+
with self.assertRaisesMessage(SMTPException, error):
2881+
backend.open()
2882+
self.assertIsNone(backend.connection)
2883+
self.assertIsNotNone(backend._partial_connection)
2884+
partial_conn = backend._partial_connection
2885+
2886+
with self.assertRaisesMessage(SMTPException, error):
2887+
backend.open()
2888+
self.assertIsNone(backend.connection)
2889+
self.assertIsNotNone(backend._partial_connection)
2890+
self.assertNotEqual(backend._partial_connection, partial_conn)
2891+
2892+
self.assertIsNone(partial_conn.sock)
2893+
self.assertIsNotNone(backend._partial_connection.sock)
2894+
28712895
@override_settings(EMAIL_USE_TLS=True)
28722896
def test_email_tls_use_settings(self):
28732897
backend = smtp.EmailBackend()
@@ -2935,20 +2959,21 @@ def test_email_ssl_keyfile_default_disabled(self):
29352959
@override_settings(EMAIL_USE_TLS=True)
29362960
def test_email_tls_attempts_starttls(self):
29372961
backend = smtp.EmailBackend()
2938-
self.assertTrue(backend.use_tls)
2962+
self.addCleanup(backend.close)
2963+
self.assertIs(backend.use_tls, True)
29392964
with self.assertRaisesMessage(
29402965
SMTPException, "STARTTLS extension not supported by server."
29412966
):
2942-
with backend:
2943-
pass
2967+
backend.open()
2968+
self.assertIsNone(backend.connection)
29442969

29452970
@override_settings(EMAIL_USE_SSL=True)
29462971
def test_email_ssl_attempts_ssl_connection(self):
29472972
backend = smtp.EmailBackend()
2948-
self.assertTrue(backend.use_ssl)
2973+
self.assertIs(backend.use_ssl, True)
29492974
with self.assertRaises(SSLError):
2950-
with backend:
2951-
pass
2975+
backend.open()
2976+
self.assertIsNone(backend.connection)
29522977

29532978
def test_connection_timeout_default(self):
29542979
"""The connection's timeout value is None by default."""
@@ -2964,10 +2989,10 @@ def __init__(self, *args, **kwargs):
29642989
super().__init__(*args, **kwargs)
29652990

29662991
myemailbackend = MyEmailBackend()
2992+
self.addCleanup(myemailbackend.close)
29672993
myemailbackend.open()
29682994
self.assertEqual(myemailbackend.timeout, 42)
29692995
self.assertEqual(myemailbackend.connection.timeout, 42)
2970-
myemailbackend.close()
29712996

29722997
@override_settings(EMAIL_TIMEOUT=10)
29732998
def test_email_timeout_override_settings(self):
@@ -3168,8 +3193,10 @@ def test_fail_silently_on_connection_error(self):
31683193
"""
31693194
with self.assertRaises(ConnectionError):
31703195
self.backend.open()
3196+
self.assertIsNone(self.backend.connection)
31713197
self.backend.fail_silently = True
31723198
self.backend.open()
3199+
self.assertIsNone(self.backend.connection)
31733200

31743201

31753202
class LegacyAPINotUsedTests(SimpleTestCase):

0 commit comments

Comments
 (0)