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 = (

Reply via email to