Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-Django4 for openSUSE:Factory checked in at 2026-06-09 14:31:14 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-Django4 (Old) and /work/SRC/openSUSE:Factory/.python-Django4.new.2375 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-Django4" Tue Jun 9 14:31:14 2026 rev:6 rq:1358152 version:4.2.30 Changes: -------- --- /work/SRC/openSUSE:Factory/python-Django4/python-Django4.changes 2026-05-06 19:23:35.287167756 +0200 +++ /work/SRC/openSUSE:Factory/.python-Django4.new.2375/python-Django4.changes 2026-06-09 14:33:15.104756627 +0200 @@ -1,0 +2,19 @@ +Tue Jun 9 09:28:13 UTC 2026 - Markéta Machová <[email protected]> + +- Add security patches: + * CVE-2026-6873: Signed cookie salt namespace collision (bsc#1267578) + * CVE-2026-6873.patch + * CVE-2026-7666: Potential unencrypted email transmission via STARTTLS + in the SMTP backend (bsc#1267579) + * CVE-2026-7666.patch + * CVE-2026-8404: Potential exposure of private data via case-sensitive + Cache-Control directives (bsc#1267580) + * CVE-2026-8404.patch + * CVE-2026-35193: Potential exposure of private data via missing + Vary: Authorization (bsc#1267576) + * CVE-2026-35193.patch + * CVE-2026-48587: Potential exposure of private data via whitespace + padding in Vary header (bsc#1267577) + * CVE-2026-48587.patch + +------------------------------------------------------------------- New: ---- CVE-2026-35193.patch CVE-2026-48587.patch CVE-2026-6873.patch CVE-2026-7666.patch CVE-2026-8404.patch ----------(New B)---------- New: Vary: Authorization (bsc#1267576) * CVE-2026-35193.patch * CVE-2026-48587: Potential exposure of private data via whitespace New: padding in Vary header (bsc#1267577) * CVE-2026-48587.patch New: * CVE-2026-6873: Signed cookie salt namespace collision (bsc#1267578) * CVE-2026-6873.patch * CVE-2026-7666: Potential unencrypted email transmission via STARTTLS New: in the SMTP backend (bsc#1267579) * CVE-2026-7666.patch * CVE-2026-8404: Potential exposure of private data via case-sensitive New: Cache-Control directives (bsc#1267580) * CVE-2026-8404.patch * CVE-2026-35193: Potential exposure of private data via missing ----------(New E)---------- ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-Django4.spec ++++++ --- /var/tmp/diff_new_pack.Y3AaAx/_old 2026-06-09 14:33:16.408810676 +0200 +++ /var/tmp/diff_new_pack.Y3AaAx/_new 2026-06-09 14:33:16.412810842 +0200 @@ -65,6 +65,16 @@ Patch10: CVE-2026-35192.patch # PATCH-FIX-UPSTREAM CVE-2026-6907.patch bsc#1264152 Patch11: CVE-2026-6907.patch +# PATCH-FIX-UPSTREAM CVE-2026-6873.patch bsc#1267578 +Patch12: CVE-2026-6873.patch +# PATCH-FIX-UPSTREAM CVE-2026-7666.patch bsc#1267579 +Patch13: CVE-2026-7666.patch +# PATCH-FIX-UPSTREAM CVE-2026-8404.patch bsc#1267580 +Patch14: CVE-2026-8404.patch +# PATCH-FIX-UPSTREAM CVE-2026-35193.patch bsc#1267576 +Patch15: CVE-2026-35193.patch +# PATCH-FIX-UPSTREAM CVE-2026-48587.patch bsc#1267577 +Patch16: CVE-2026-48587.patch BuildRequires: %{python_module Jinja2 >= 2.9.2} BuildRequires: %{python_module Pillow >= 6.2.0} BuildRequires: %{python_module PyYAML} ++++++ CVE-2026-35193.patch ++++++ >From 050a3dc276f9142067260e990e4d8d42d5e32863 Mon Sep 17 00:00:00 2001 From: Jacob Walls <[email protected]> Date: Tue, 24 Mar 2026 14:06:22 -0400 Subject: [PATCH] [5.2.x] Fixed CVE-2026-35193 -- Varied on Authorization when caching non-public responses. Thanks Shai Berger for the report, and Natalia Bidart and Sarah Boyce for reviews. Backport of a2faa8e895926ac5d63f72879b5ccf671b5b4ba9 from main. --- django/middleware/cache.py | 9 +++++++++ docs/releases/5.2.15.txt | 13 +++++++++++++ docs/topics/cache.txt | 12 ++++++++++++ tests/cache/tests.py | 25 +++++++++++++++++++++++++ 4 files changed, 59 insertions(+) Index: Django-4.2.11/django/middleware/cache.py =================================================================== --- Django-4.2.11.orig/django/middleware/cache.py +++ Django-4.2.11/django/middleware/cache.py @@ -41,6 +41,8 @@ More details about how the caching works * This middleware also sets ETag, Last-Modified, Expires and Cache-Control headers on the response object. +* If the request had an Authorization header and the response was not marked + "Cache-Control: public", the response will vary on Authorization. """ from django.conf import settings @@ -51,6 +53,7 @@ from django.utils.cache import ( has_vary_header, learn_cache_key, patch_response_headers, + patch_vary_headers, ) from django.utils.deprecation import MiddlewareMixin @@ -127,6 +130,12 @@ class UpdateCacheMiddleware(MiddlewareMi # max-age was set to 0, don't cache. return response patch_response_headers(response, timeout) + # Make the response vary on Authorization if the request bears that + # header, unless allowed by "public" per RFC 9111, Section 3.5. No + # exceptions are made for "s-maxage" and "must-revalidate" since these + # are not currently implemented by Django. + if request.headers.get("Authorization") and "public" not in cache_control: + patch_vary_headers(response, ("Authorization",)) if timeout and response.status_code == 200: cache_key = learn_cache_key( request, response, timeout, self.key_prefix, cache=self.cache Index: Django-4.2.11/docs/topics/cache.txt =================================================================== --- Django-4.2.11.orig/docs/topics/cache.txt +++ Django-4.2.11/docs/topics/cache.txt @@ -1394,6 +1394,18 @@ second argument. For more on Vary headers, see the :rfc:`official Vary spec <9110#section-12.5.5>`. +.. admonition:: ``CacheMiddleware`` varies on ``Authorization`` automatically + + Although varying on ``Authorization`` is not strictly necessary given that + :rfc:`9111#section-3.5` allows caches to avoid reusing authenticated + responses, Django's ``CacheMiddleware`` adds ``Authorization`` to the + ``Vary`` header to simplify construction of cache keys. + +.. versionchanged:: 6.0.6 + + Previously, ``UpdateCacheMiddleware`` did not vary on ``Authorization`` for + requests bearing that header. + Controlling cache: Using other headers ====================================== Index: Django-4.2.11/tests/cache/tests.py =================================================================== --- Django-4.2.11.orig/tests/cache/tests.py +++ Django-4.2.11/tests/cache/tests.py @@ -56,6 +56,7 @@ from django.test.utils import CaptureQue from django.utils import timezone, translation from django.utils.cache import ( get_cache_key, + has_vary_header, learn_cache_key, patch_cache_control, patch_vary_headers, @@ -2727,6 +2728,30 @@ class CacheMiddlewareTest(SimpleTestCase response = view(request, "2") self.assertEqual(response.content, b"Hello World 2") + def test_vary_on_authorization_for_authorization_header(self): + view_with_cache = cache_page(3)(hello_world_view) + request = self.factory.get("/view/", headers={"Authorization": "token"}) + response = view_with_cache(request, "1") + self.assertIs(has_vary_header(response, "Authorization"), True) + + def test_no_vary_on_authorization_for_empty_authorization_header(self): + view_with_cache = cache_page(3)(hello_world_view) + request = self.factory.get("/view/", headers={"Authorization": ""}) + response = view_with_cache(request, "1") + self.assertIs(has_vary_header(response, "Authorization"), False) + + def test_authorization_header_exceptions(self): + """ + Responses to requests with an ``Authorization`` header are not made to + vary on ``Authorization`` when ``Cache-Control: public`` is present. + ``s-maxage`` and ``must-revalidate`` are also exceptions per RFC 9111, + Section 3.5, but Django does not implement them. + """ + view_with_cache = cache_page(3)(cache_control(public=True)(hello_world_view)) + request = self.factory.get("/view/", headers={"Authorization": "token"}) + response = view_with_cache(request, "1") + self.assertIs(has_vary_header(response, "Authorization"), False) + def test_sensitive_cookie_not_cached(self): """ Django must prevent caching of responses that set a user-specific (and ++++++ CVE-2026-48587.patch ++++++ >From 9b62b0af71a14c657d19d95371630ba839e83d9a Mon Sep 17 00:00:00 2001 From: Jake Howard <[email protected]> Date: Fri, 15 May 2026 14:44:28 +0100 Subject: [PATCH] [5.2.x] Fixed CVE-2026-48587 -- Ignored whitespace padding when checking Vary header values. Thanks to Navid Rezazadeh for the report and Jacob Walls for review. Backport of 42aa0b3364d312e7c6472258d8b0e9c0277fbf22 from main. --- django/utils/cache.py | 4 ++-- docs/releases/5.2.15.txt | 13 +++++++++++++ tests/cache/tests.py | 38 ++++++++++++++++++++++++++++++++++---- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/django/utils/cache.py b/django/utils/cache.py index 3b014fbe5141..253c68778447 100644 --- a/django/utils/cache.py +++ b/django/utils/cache.py @@ -331,8 +331,8 @@ def has_vary_header(response, header_query): if not response.has_header("Vary"): return False vary_headers = cc_delim_re.split(response.headers["Vary"]) - existing_headers = {header.lower() for header in vary_headers} - return header_query.lower() in existing_headers + existing_headers = {header.lower().strip() for header in vary_headers} + return header_query.lower().strip() in existing_headers def _i18n_cache_key_suffix(request, cache_key): diff --git a/tests/cache/tests.py b/tests/cache/tests.py index c144d3bddae2..5b16735d27b8 100644 --- a/tests/cache/tests.py +++ b/tests/cache/tests.py @@ -4,7 +4,6 @@ import io import os import pickle -import re import shutil import sys import tempfile @@ -56,6 +55,7 @@ from django.test.utils import CaptureQueriesContext from django.utils import timezone, translation from django.utils.cache import ( + cc_delim_re, get_cache_key, has_vary_header, learn_cache_key, @@ -2198,8 +2198,6 @@ def test_patch_cache_control(self): ), ) - cc_delim_re = re.compile(r"\s*,\s*") - for initial_cc, newheaders, expected_cc in tests: with self.subTest(initial_cc=initial_cc, newheaders=newheaders): response = HttpResponse() @@ -2209,6 +2207,31 @@ def test_patch_cache_control(self): parts = set(cc_delim_re.split(response.headers["Cache-Control"])) self.assertEqual(parts, expected_cc) + def test_has_vary_header(self): + tests = [ + ("*", "*", True), + ("Cookie, *", "*", True), + ("Cookie,*", "*", True), + ("Cookie , *", "*", True), + # Surronding whitespace on values must be stripped independently of + # the comma delimiter. + ("* ", "*", True), + (" *", "*", True), + ("Cookie, * ", "*", True), + (" Cookie", "Cookie", True), + ("Cookie", "*", False), + ("*", "Cookie", False), + ("cookie", "Cookie", True), + ("Cookie", "cookie", True), + ] + + for header_value, header_query, has_match in tests: + with self.subTest(header_value=header_value, header_query=header_query): + response = HttpResponse() + response.headers["Vary"] = header_value + + self.assertIs(has_vary_header(response, header_query), has_match) + @override_settings( CACHES={ @@ -2512,9 +2535,15 @@ def hello_world_view_patch_vary_headers_asterisk(request, value): return response +def hello_world_view_patch_vary_headers_asterisk_space(request, value): + response = HttpResponse("Hello World %s" % value) + patch_vary_headers(response, (" * ",)) + return response + + def hello_world_view_vary_headers_includes_asterisk(request, value): response = HttpResponse("Hello World %s" % value) - response["Vary"] = "Cookie, *, Pony" + response["Vary"] = "Cookie, * , Pony" return response @@ -2758,6 +2787,7 @@ def view(request, value): def test_vary_asterisk_not_cached(self): views_with_cache = ( cache_page(3)(hello_world_view_patch_vary_headers_asterisk), + cache_page(3)(hello_world_view_patch_vary_headers_asterisk_space), cache_page(3)(hello_world_view_vary_headers_includes_asterisk), ) for view in views_with_cache: ++++++ CVE-2026-6873.patch ++++++ >From 594360cbf58be7f56eb6da96d58644297c99ef85 Mon Sep 17 00:00:00 2001 From: Paul McMillan <[email protected]> Date: Fri, 8 May 2026 16:13:58 -0400 Subject: [PATCH] [5.2.x] Fixed CVE-2026-6873 -- Prevented signed cookie salt namespace collisions. Made signed cookies derive their signer namespace from an injective encoding of `(name, salt)` while preserving compatibility with legacy `name + salt` cookies behind SIGNED_COOKIE_LEGACY_SALT_FALLBACK. Thanks Peng Zhou for the report, and Shai Berger, Markus Holterman, Jake Howard, and Paul McMillan for reviews. Co-authored-by: Jacob Walls <[email protected]> Co-authored-by: Natalia <[email protected]> Backport of 70d36515b9cc71700105a14b275583070d48b689 from main. --- django/conf/global_settings.py | 1 + django/core/signing.py | 24 ++++++++++++++ django/http/request.py | 4 +-- django/http/response.py | 4 ++- docs/ref/request-response.txt | 23 +++++++++---- docs/ref/settings.txt | 19 +++++++++++ docs/releases/5.2.15.txt | 17 ++++++++++ tests/signed_cookies_tests/tests.py | 50 +++++++++++++++++++++++++++++ 8 files changed, 132 insertions(+), 10 deletions(-) diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index f4535acb09c9..00c5778bc80c 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -555,6 +555,7 @@ def gettext_noop(s): # SIGNING # ########### +SIGNED_COOKIE_LEGACY_SALT_FALLBACK = True SIGNING_BACKEND = "django.core.signing.TimestampSigner" ######## diff --git a/django/core/signing.py b/django/core/signing.py index e3d778591092..faea20eadf35 100644 --- a/django/core/signing.py +++ b/django/core/signing.py @@ -106,6 +106,30 @@ def _cookie_signer_key(key): return b"django.http.cookies" + force_bytes(key) +def _cookie_signer_salt(cookie_name, salt=""): + # Prefix the salt length so (cookie_name, salt) pairs can't collide. + return f"django.http.cookies.v2:{len(salt)}:{salt}{cookie_name}" + + +def _cookie_signer_legacy_salt(cookie_name, salt=""): + return cookie_name + salt + + +def _unsign_cookie(signed_value, *, cookie_name, salt="", max_age=None): + try: + return get_cookie_signer(salt=_cookie_signer_salt(cookie_name, salt)).unsign( + signed_value, max_age=max_age + ) + except BadSignature as exc: + if settings.SIGNED_COOKIE_LEGACY_SALT_FALLBACK and not isinstance( + exc, SignatureExpired + ): + return get_cookie_signer( + salt=_cookie_signer_legacy_salt(cookie_name, salt) + ).unsign(signed_value, max_age=max_age) + raise + + def get_cookie_signer(salt="django.core.signing.get_cookie_signer"): Signer = import_string(settings.SIGNING_BACKEND) return Signer( diff --git a/django/http/request.py b/django/http/request.py index 7771f507d367..f96051e809f3 100644 --- a/django/http/request.py +++ b/django/http/request.py @@ -243,8 +243,8 @@ def get_signed_cookie(self, key, default=RAISE_ERROR, salt="", max_age=None): else: raise try: - value = signing.get_cookie_signer(salt=key + salt).unsign( - cookie_value, max_age=max_age + value = signing._unsign_cookie( + cookie_value, cookie_name=key, salt=salt, max_age=max_age ) except signing.BadSignature: if default is not RAISE_ERROR: diff --git a/django/http/response.py b/django/http/response.py index 8b23a9251704..36fd0d4ceb30 100644 --- a/django/http/response.py +++ b/django/http/response.py @@ -284,7 +284,9 @@ def setdefault(self, key, value): self.headers.setdefault(key, value) def set_signed_cookie(self, key, value, salt="", **kwargs): - value = signing.get_cookie_signer(salt=key + salt).sign(value) + value = signing.get_cookie_signer( + salt=signing._cookie_signer_salt(key, salt) + ).sign(value) return self.set_cookie(key, value, **kwargs) def delete_cookie(self, key, path="/", domain=None, samesite=None): diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt index 79419ea56e18..65a7f5519a5b 100644 --- a/docs/ref/request-response.txt +++ b/docs/ref/request-response.txt @@ -393,11 +393,14 @@ Methods no longer valid. If you provide the ``default`` argument the exception will be suppressed and that default value will be returned instead. - The optional ``salt`` argument can be used to provide extra protection - against brute force attacks on your secret key. If supplied, the - ``max_age`` argument will be checked against the signed timestamp - attached to the cookie value to ensure the cookie is not older than - ``max_age`` seconds. + The optional ``salt`` argument can be used to put the cookie into a + separate signature namespace. If supplied, the ``max_age`` argument will + be checked against the signed timestamp attached to the cookie value to + ensure the cookie is not older than ``max_age`` seconds. + + Cookies signed by older Django versions are accepted by default for + backwards compatibility. Set :setting:`SIGNED_COOKIE_LEGACY_SALT_FALLBACK` + to ``False`` to reject them. For example: @@ -420,6 +423,11 @@ Methods See :doc:`cryptographic signing </topics/signing>` for more information. + .. versionchanged:: 5.2.15 + + In older versions, cookies signed with distinct ``(key, salt)`` pairs + that concatenate to the same string could be used interchangeably. + .. method:: HttpRequest.is_secure() Returns ``True`` if the request is secure; that is, if it was made with @@ -1043,8 +1051,9 @@ Methods Like :meth:`~HttpResponse.set_cookie()`, but :doc:`cryptographic signing </topics/signing>` the cookie before setting it. Use in conjunction with :meth:`HttpRequest.get_signed_cookie`. - You can use the optional ``salt`` argument for added key strength, but - you will need to remember to pass it to the corresponding + You can use the optional ``salt`` argument to put the cookie into a + separate signature namespace, but you will need to remember to pass it to + the corresponding :meth:`HttpRequest.get_signed_cookie` call. .. method:: HttpResponse.delete_cookie(key, path='/', domain=None, samesite=None) diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 1e921160990e..afdad020d52a 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -2596,6 +2596,24 @@ precedence and will be applied instead. See See also :setting:`DATE_FORMAT` and :setting:`SHORT_DATE_FORMAT`. +.. setting:: SIGNED_COOKIE_LEGACY_SALT_FALLBACK + +``SIGNED_COOKIE_LEGACY_SALT_FALLBACK`` +--------------------------------------- + +.. versionadded:: 5.2.15 + +Default: ``True`` + +Controls whether :meth:`~django.http.HttpRequest.get_signed_cookie` accepts +cookies signed with Django's historical signed-cookie salt derivation based on +``key + salt``. + +Set this to ``False`` to reject those legacy signed cookies and only accept +cookies signed with Django's current unambiguous signed-cookie salt derivation. +This transitional setting will be removed in Django 7.0, when the legacy signed +cookies will no longer be accepted. + .. setting:: SIGNING_BACKEND ``SIGNING_BACKEND`` @@ -3748,6 +3766,7 @@ HTTP * :setting:`SECURE_REFERRER_POLICY` * :setting:`SECURE_SSL_HOST` * :setting:`SECURE_SSL_REDIRECT` +* :setting:`SIGNED_COOKIE_LEGACY_SALT_FALLBACK` * :setting:`SIGNING_BACKEND` * :setting:`USE_X_FORWARDED_HOST` * :setting:`USE_X_FORWARDED_PORT` diff --git a/tests/signed_cookies_tests/tests.py b/tests/signed_cookies_tests/tests.py index 876887d883f1..62bd3d192dbe 100644 --- a/tests/signed_cookies_tests/tests.py +++ b/tests/signed_cookies_tests/tests.py @@ -6,6 +6,7 @@ from django.test.utils import freeze_time +@override_settings(SIGNED_COOKIE_LEGACY_SALT_FALLBACK=False) class SignedCookieTest(SimpleTestCase): def test_can_set_and_read_signed_cookies(self): response = HttpResponse() @@ -27,6 +28,55 @@ def test_can_use_salt(self): with self.assertRaises(signing.BadSignature): request.get_signed_cookie("a", salt="two") + def test_salt_namespace_is_unambiguous(self): + response = HttpResponse() + response.set_signed_cookie("a", "hello", salt="bc") + request = HttpRequest() + request.COOKIES["ab"] = response.cookies["a"].value + with self.assertRaises(signing.BadSignature): + request.get_signed_cookie("ab", salt="c") + + @override_settings(SIGNED_COOKIE_LEGACY_SALT_FALLBACK=True) + def test_expired_legacy_cookie_raises_signature_expired(self): + with freeze_time(123456789): + request = HttpRequest() + request.COOKIES["a"] = signing.get_cookie_signer( + salt=signing._cookie_signer_legacy_salt("a", "bc") + ).sign("hello") + with freeze_time(123456800): + with self.assertRaises(signing.SignatureExpired): + request.get_signed_cookie("a", salt="bc", max_age=10) + + @override_settings(SIGNED_COOKIE_LEGACY_SALT_FALLBACK=True) + def test_legacy_salt_namespace_is_accepted_by_default(self): + request = HttpRequest() + # Simulate an attack along the lines of CVE-2026-6873, where a value + # for the "a" cookie is submitted as the value for another cookie. + request.COOKIES["ab"] = signing.get_cookie_signer( + salt=signing._cookie_signer_legacy_salt("a", "bc") + ).sign("hello") + # No protection since SIGNED_COOKIE_LEGACY_SALT_FALLBACK=True. + self.assertEqual(request.get_signed_cookie("ab", salt="c"), "hello") + + def test_legacy_salt_namespace_not_accepted(self): + request = HttpRequest() + request.COOKIES["a"] = signing.get_cookie_signer( + salt=signing._cookie_signer_legacy_salt("a", "bc") + ).sign("hello") + with self.assertRaises(signing.BadSignature): + request.get_signed_cookie("a", salt="bc") + + @override_settings(SIGNED_COOKIE_LEGACY_SALT_FALLBACK=True) + def test_expired_new_style_cookie_does_not_fallback_to_legacy_salt(self): + with freeze_time(123456789): + response = HttpResponse() + response.set_signed_cookie("a", "hello", salt="bc") + request = HttpRequest() + request.COOKIES["a"] = response.cookies["a"].value + with freeze_time(123456800): + with self.assertRaises(signing.SignatureExpired): + request.get_signed_cookie("a", salt="bc", max_age=10) + def test_detects_tampering(self): response = HttpResponse() response.set_signed_cookie("c", "hello") ++++++ CVE-2026-7666.patch ++++++ >From 4e47d2b800435bcbfd1301ef3250b9c7fb8fa670 Mon Sep 17 00:00:00 2001 From: Natalia <[email protected]> Date: Wed, 27 May 2026 11:19:57 -0300 Subject: [PATCH] [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 df887f50198593a0e5b4638bfddbbd43a30fd276 from main. --- django/core/mail/backends/smtp.py | 54 ++++++++++++++++++++----------- docs/releases/5.2.15.txt | 13 ++++++++ tests/mail/tests.py | 41 +++++++++++++++++++---- 3 files changed, 83 insertions(+), 25 deletions(-) diff --git a/django/core/mail/backends/smtp.py b/django/core/mail/backends/smtp.py index 6820148ac122..b47a8a2d57cd 100644 --- a/django/core/mail/backends/smtp.py +++ b/django/core/mail/backends/smtp.py @@ -50,6 +50,7 @@ def __init__( "one of those settings to True." ) self.connection = None + self._partial_connection = None self._lock = threading.RLock() @property @@ -75,6 +76,11 @@ def open(self): # 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()} @@ -83,39 +89,51 @@ def open(self): 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/tests/mail/tests.py b/tests/mail/tests.py index bc161ab06f84..e27b10796a8a 100644 --- a/tests/mail/tests.py +++ b/tests/mail/tests.py @@ -2264,8 +2264,12 @@ def test_server_open(self): backend = smtp.EmailBackend(username="", password="") 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 = smtp.EmailBackend() @@ -2273,6 +2277,26 @@ def test_reopen_connection(self): backend.connection = mock.Mock(spec=object()) self.assertIs(backend.open(), False) + def test_reopen_replaces_partial_connection(self): + backend = smtp.EmailBackend(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) + @override_settings(EMAIL_USE_TLS=True) def test_email_tls_use_settings(self): backend = smtp.EmailBackend() @@ -2340,20 +2364,21 @@ def test_email_ssl_keyfile_default_disabled(self): @override_settings(EMAIL_USE_TLS=True) def test_email_tls_attempts_starttls(self): backend = smtp.EmailBackend() - self.assertTrue(backend.use_tls) + 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) @override_settings(EMAIL_USE_SSL=True) def test_email_ssl_attempts_ssl_connection(self): backend = smtp.EmailBackend() - self.assertTrue(backend.use_ssl) + 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): """The connection's timeout value is None by default.""" @@ -2369,10 +2394,10 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) myemailbackend = MyEmailBackend() + self.addCleanup(myemailbackend.close) myemailbackend.open() self.assertEqual(myemailbackend.timeout, 42) self.assertEqual(myemailbackend.connection.timeout, 42) - myemailbackend.close() @override_settings(EMAIL_TIMEOUT=10) def test_email_timeout_override_settings(self): @@ -2546,5 +2571,7 @@ def test_fail_silently_on_connection_error(self): """ 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) ++++++ CVE-2026-8404.patch ++++++ >From 366d9ae6e8d1469c04e9ebdc1bcd098fc14a3b1e Mon Sep 17 00:00:00 2001 From: Jake Howard <[email protected]> Date: Tue, 19 Aug 2025 15:40:37 +0800 Subject: [PATCH] [5.2.x] Fixed CVE-2026-8404 -- Used Cache-Control directives case-insensitively in UpdateCacheMiddleware. Thanks Ahmed Badawe for the report, and Jacob Walls for reviews. This commit includes: [5.2.x] Fixed #36560 -- Prevented UpdateCacheMiddleware from caching responses with Cache-Control 'no-cache' or 'no-store'. Backport of ed7c1a56400d64f109f30df3ce697984cdad7c75 from main. Backport of d618d7ae4fec727d5b582bd24f803c28d17bf7cd from main. --- django/middleware/cache.py | 13 +++++++++++-- docs/releases/5.2.15.txt | 16 ++++++++++++++++ tests/cache/tests.py | 29 +++++++++++++++++++---------- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/django/middleware/cache.py b/django/middleware/cache.py index 880ceaf7c15a..74ff71563636 100644 --- a/django/middleware/cache.py +++ b/django/middleware/cache.py @@ -100,8 +100,17 @@ def process_response(self, request, response): ): return response - # Don't cache a response with 'Cache-Control: private' - if "private" in response.get("Cache-Control", ()): + # Don't cache responses when the Cache-Control header is set to + # private, no-cache, or no-store. + cache_control = response.get("Cache-Control", "").lower() + if cache_control and any( + directive in cache_control + for directive in ( + "private", + "no-cache", + "no-store", + ) + ): return response # Don't cache responses when the Vary header contains '*'. diff --git a/tests/cache/tests.py b/tests/cache/tests.py index 306a6ac3ffca..36532d9afca3 100644 --- a/tests/cache/tests.py +++ b/tests/cache/tests.py @@ -2734,16 +2734,25 @@ def test_cache_page_timeout(self): ) cache.clear() - def test_cached_control_private_not_cached(self): - """Responses with 'Cache-Control: private' are not cached.""" - view_with_private_cache = cache_page(3)( - cache_control(private=True)(hello_world_view) - ) - request = self.factory.get("/view/") - response = view_with_private_cache(request, "1") - self.assertEqual(response.content, b"Hello World 1") - response = view_with_private_cache(request, "2") - self.assertEqual(response.content, b"Hello World 2") + def test_cache_control_not_cached(self): + """ + Responses with 'Cache-Control: private/no-cache/no-store' are + not cached. + """ + for cc in ("private", "no-cache", "no-store", "PRIVATE", "NO-store"): + with self.subTest(cache_control=cc): + # Cannot use @cache_control() as it lowercases directives. + @cache_page(3) + def view(request, value): + return HttpResponse( + f"Hello World {value}", headers={"Cache-Control": cc} + ) + + request = self.factory.get("/view/") + response = view(request, "1") + self.assertEqual(response.content, b"Hello World 1") + response = view(request, "2") + self.assertEqual(response.content, b"Hello World 2") def test_vary_asterisk_not_cached(self): views_with_cache = (
