티스토리 수익 글 보기

티스토리 수익 글 보기

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

Commit 4e47d2b

Browse files
committed
[5.2.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 594360c commit 4e47d2b

3 files changed

Lines changed: 83 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
@@ -50,6 +50,7 @@ def __init__(
5050
"one of those settings to True."
5151
)
5252
self.connection = None
53+
self._partial_connection = None
5354
self._lock = threading.RLock()
5455

5556
@property
@@ -75,6 +76,11 @@ def open(self):
7576
# Nothing to do if the connection is already open.
7677
return False
7778

79+
# If a connection was partially opened before, close it.
80+
if self._partial_connection is not None:
81+
self._close_connection(self._partial_connection)
82+
self._partial_connection = None
83+
7884
# If local_hostname is not specified, socket.getfqdn() gets used.
7985
# For performance, we use the cached FQDN for local_hostname.
8086
connection_params = {"local_hostname": DNS_NAME.get_fqdn()}
@@ -83,39 +89,51 @@ def open(self):
8389
if self.use_ssl:
8490
connection_params["context"] = self.ssl_context
8591
try:
86-
self.connection = self.connection_class(
92+
self._partial_connection = self.connection_class(
8793
self.host, self.port, **connection_params
8894
)
8995

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

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

120138
def send_messages(self, email_messages):
121139
"""

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>`.

tests/mail/tests.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2264,15 +2264,39 @@ def test_server_open(self):
22642264
backend = smtp.EmailBackend(username="", password="")
22652265
self.assertIsNone(backend.connection)
22662266
opened = backend.open()
2267+
self.assertIsNotNone(backend.connection)
2268+
self.assertIsNone(backend._partial_connection)
22672269
backend.close()
22682270
self.assertIs(opened, True)
2271+
self.assertIsNone(backend.connection)
2272+
self.assertIsNone(backend._partial_connection)
22692273

22702274
def test_reopen_connection(self):
22712275
backend = smtp.EmailBackend()
22722276
# Simulate an already open connection.
22732277
backend.connection = mock.Mock(spec=object())
22742278
self.assertIs(backend.open(), False)
22752279

2280+
def test_reopen_replaces_partial_connection(self):
2281+
backend = smtp.EmailBackend(username="not empty", password="not empty")
2282+
self.addCleanup(backend.close)
2283+
2284+
error = "SMTP AUTH extension not supported by server."
2285+
with self.assertRaisesMessage(SMTPException, error):
2286+
backend.open()
2287+
self.assertIsNone(backend.connection)
2288+
self.assertIsNotNone(backend._partial_connection)
2289+
partial_conn = backend._partial_connection
2290+
2291+
with self.assertRaisesMessage(SMTPException, error):
2292+
backend.open()
2293+
self.assertIsNone(backend.connection)
2294+
self.assertIsNotNone(backend._partial_connection)
2295+
self.assertNotEqual(backend._partial_connection, partial_conn)
2296+
2297+
self.assertIsNone(partial_conn.sock)
2298+
self.assertIsNotNone(backend._partial_connection.sock)
2299+
22762300
@override_settings(EMAIL_USE_TLS=True)
22772301
def test_email_tls_use_settings(self):
22782302
backend = smtp.EmailBackend()
@@ -2340,20 +2364,21 @@ def test_email_ssl_keyfile_default_disabled(self):
23402364
@override_settings(EMAIL_USE_TLS=True)
23412365
def test_email_tls_attempts_starttls(self):
23422366
backend = smtp.EmailBackend()
2343-
self.assertTrue(backend.use_tls)
2367+
self.addCleanup(backend.close)
2368+
self.assertIs(backend.use_tls, True)
23442369
with self.assertRaisesMessage(
23452370
SMTPException, "STARTTLS extension not supported by server."
23462371
):
2347-
with backend:
2348-
pass
2372+
backend.open()
2373+
self.assertIsNone(backend.connection)
23492374

23502375
@override_settings(EMAIL_USE_SSL=True)
23512376
def test_email_ssl_attempts_ssl_connection(self):
23522377
backend = smtp.EmailBackend()
2353-
self.assertTrue(backend.use_ssl)
2378+
self.assertIs(backend.use_ssl, True)
23542379
with self.assertRaises(SSLError):
2355-
with backend:
2356-
pass
2380+
backend.open()
2381+
self.assertIsNone(backend.connection)
23572382

23582383
def test_connection_timeout_default(self):
23592384
"""The connection's timeout value is None by default."""
@@ -2369,10 +2394,10 @@ def __init__(self, *args, **kwargs):
23692394
super().__init__(*args, **kwargs)
23702395

23712396
myemailbackend = MyEmailBackend()
2397+
self.addCleanup(myemailbackend.close)
23722398
myemailbackend.open()
23732399
self.assertEqual(myemailbackend.timeout, 42)
23742400
self.assertEqual(myemailbackend.connection.timeout, 42)
2375-
myemailbackend.close()
23762401

23772402
@override_settings(EMAIL_TIMEOUT=10)
23782403
def test_email_timeout_override_settings(self):
@@ -2546,5 +2571,7 @@ def test_fail_silently_on_connection_error(self):
25462571
"""
25472572
with self.assertRaises(ConnectionError):
25482573
self.backend.open()
2574+
self.assertIsNone(self.backend.connection)
25492575
self.backend.fail_silently = True
25502576
self.backend.open()
2577+
self.assertIsNone(self.backend.connection)

0 commit comments

Comments
 (0)