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-10-08 13:28:11
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-Django4 (Old)
 and      /work/SRC/openSUSE:Factory/.python-Django4.new.3698736 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-Django4"

Thu Oct  8 13:28:11 2026 rev:9 rq:1383274 version:4.2.30

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-Django4/python-Django4.changes    
2026-08-06 16:27:30.747562078 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-Django4.new.3698736/python-Django4.changes   
    2026-10-08 13:28:54.095593097 +0200
@@ -1,0 +2,23 @@
+Thu Oct  8 10:23:59 UTC 2026 - Markéta Machová <[email protected]>
+
+- Add security patches:
+  * CVE-2026-87975: an authenticated user permitted to submit a
+    formset to delete rows outside the limiting queryset, without any
+    permission on the targeted object, marking an out-of-queryset
+    object for deletion (bsc#1284471)
+    * CVE-2026-87975.patch
+  * CVE-2026-87890: An incomplete fix for CVE-2026-15307 in Django
+    spatial lookups allows an attacker who can supply `bytes` values
+    to cause the Django process to make network requests via a
+    crafted VRT document (bsc#1284472)
+    * CVE-2026-87890.patch
+  * CVE-2026-84429: potential denial-of-service attack due to
+    quadratic time complexity when parsing a value with many
+    separators inside a quoted parameter (bsc#1284476)
+    * CVE-2026-84429.patch
+  * CVE-2026-77050: potential denial-of-service in
+    `get_supported_language_variant()` when processing many distinct,
+    very long language codes (bsc#1284481)
+    * CVE-2026-77050.patch
+
+-------------------------------------------------------------------

New:
----
  CVE-2026-77050.patch
  CVE-2026-84429.patch
  CVE-2026-87890.patch
  CVE-2026-87975.patch

----------(New B)----------
  New:    very long language codes (bsc#1284481)
    * CVE-2026-77050.patch
  New:    separators inside a quoted parameter (bsc#1284476)
    * CVE-2026-84429.patch
  * CVE-2026-77050: potential denial-of-service in
  New:    crafted VRT document (bsc#1284472)
    * CVE-2026-87890.patch
  * CVE-2026-84429: potential denial-of-service attack due to
  New:    object for deletion (bsc#1284471)
    * CVE-2026-87975.patch
  * CVE-2026-87890: An incomplete fix for CVE-2026-15307 in Django
----------(New E)----------

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-Django4.spec ++++++
--- /var/tmp/diff_new_pack.lhUUaz/_old  2026-10-08 13:28:55.853666671 +0200
+++ /var/tmp/diff_new_pack.lhUUaz/_new  2026-10-08 13:28:55.860666964 +0200
@@ -87,6 +87,15 @@
 Patch21:        CVE-2026-15830.patch
 # PATCH-FIX-UPSTREAM CVE-2026-15920.patch bsc#1273000
 Patch22:        CVE-2026-15920.patch
+# PATCH-FIX-UPSTREAM CVE-2026-77050.patch bsc#1284481
+# combined with bits of 
https://github.com/django/django/commit/0e94f292cda632153f2b3d9a9037eb0141ae9c2e
+Patch23:        CVE-2026-77050.patch
+# PATCH-FIX-UPSTREAM CVE-2026-84429.patch bsc#1284476
+Patch24:        CVE-2026-84429.patch
+# PATCH-FIX-UPSTREAM CVE-2026-87890.patch bsc#1284472
+Patch25:        CVE-2026-87890.patch
+# PATCH-FIX-UPSTREAM CVE-2026-87975.patch bsc#1284471
+Patch26:        CVE-2026-87975.patch
 BuildRequires:  %{python_module Jinja2 >= 2.9.2}
 BuildRequires:  %{python_module Pillow >= 6.2.0}
 BuildRequires:  %{python_module PyYAML}

++++++ CVE-2026-77050.patch ++++++
>From 02a69e3791e3df23d45ea4ea7e7fc489f0eef2be Mon Sep 17 00:00:00 2001
From: Sarah Boyce <[email protected]>
Date: Wed, 19 Aug 2026 12:44:41 +0200
Subject: [PATCH] [5.2.x] Fixed CVE-2026-77050 -- Prevented memory exhaustion
 in get_supported_language_variant().

The fix for CVE-2024-39614 limited the length of language codes processed by
get_supported_language_variant(), but the full language code was retained as
an lru_cache key before the length check.

Moved caching after language code truncation and validation to prevent
excessively long language codes from being retained in memory.

Thank you to Gleb Lizunov for the report and Jacob Walls for the review.

Backport of c88b304cc2d90fc37d3bd1f5f3829706fa6c13bc from main.
---
 django/utils/translation/trans_real.py | 72 +++++++++++++-------------
 docs/releases/5.2.18.txt               | 14 +++++
 tests/i18n/tests.py                    | 39 ++++++++++++++
 3 files changed, 88 insertions(+), 37 deletions(-)

diff --git a/django/utils/translation/trans_real.py 
b/django/utils/translation/trans_real.py
index 06459860aa63..a47ca3008582 100644
--- a/django/utils/translation/trans_real.py
+++ b/django/utils/translation/trans_real.py
@@ -68,7 +68,7 @@ def reset_cache(*, setting, **kwargs):
     if setting in ("LANGUAGES", "LANGUAGE_CODE"):
         translation_catalog_exists.cache_clear()
         get_languages.cache_clear()
-        get_supported_language_variant.cache_clear()
+        _get_supported_language_variant.cache_clear()
 
 
 class TranslationCatalog:
@@ -499,6 +499,31 @@ def get_languages():
 
 
 @functools.lru_cache(maxsize=1000)
+def _get_supported_language_variant(lang_code, strict=False):
+    # If 'zh-hant-tw' is not supported, try special fallback or subsequent
+    # language codes i.e. 'zh-hant' and 'zh'.
+    possible_lang_codes = [lang_code]
+    try:
+        possible_lang_codes.extend(LANG_INFO[lang_code]["fallback"])
+    except KeyError:
+        pass
+    i = None
+    while (i := lang_code.rfind("-", 0, i)) > -1:
+        possible_lang_codes.append(lang_code[:i])
+    generic_lang_code = possible_lang_codes[-1]
+    supported_lang_codes = get_languages()
+
+    for code in possible_lang_codes:
+        if code.lower() in supported_lang_codes and check_for_language(code):
+            return code
+    if not strict:
+        # if fr-fr is not supported, try fr-ca.
+        for supported_code in supported_lang_codes:
+            if supported_code.startswith(generic_lang_code + "-"):
+                return supported_code
+    raise LookupError(lang_code)
+
+
 def get_supported_language_variant(lang_code, strict=False):
     """
     Return the language code that's listed in supported languages, possibly
@@ -509,44 +534,17 @@ def get_supported_language_variant(lang_code, 
strict=False):
 
     The language code is truncated to a maximum length to avoid potential
     denial of service attacks.
-
-    lru_cache should have a maxsize to prevent from memory exhaustion attacks,
-    as the provided language codes are taken from the HTTP request. See also
-    <https://www.djangoproject.com/weblog/2007/oct/26/security-fix/>.
     """
     if lang_code:
-        # Truncate the language code to a maximum length to avoid potential
-        # denial of service attacks.
-        if len(lang_code) > LANGUAGE_CODE_MAX_LENGTH:
-            if (
-                not strict
-                and (index := lang_code.rfind("-", 0, 
LANGUAGE_CODE_MAX_LENGTH)) > 0
-            ):
-                # There is a generic variant under the maximum length accepted 
length.
-                lang_code = lang_code[:index]
-            else:
-                raise LookupError(lang_code)
-        # If 'zh-hant-tw' is not supported, try special fallback or subsequent
-        # language codes i.e. 'zh-hant' and 'zh'.
-        possible_lang_codes = [lang_code]
-        try:
-            possible_lang_codes.extend(LANG_INFO[lang_code]["fallback"])
-        except KeyError:
-            pass
-        i = None
-        while (i := lang_code.rfind("-", 0, i)) > -1:
-            possible_lang_codes.append(lang_code[:i])
-        generic_lang_code = possible_lang_codes[-1]
-        supported_lang_codes = get_languages()
-
-        for code in possible_lang_codes:
-            if code.lower() in supported_lang_codes and 
check_for_language(code):
-                return code
-        if not strict:
-            # if fr-fr is not supported, try fr-ca.
-            for supported_code in supported_lang_codes:
-                if supported_code.startswith(generic_lang_code + "-"):
-                    return supported_code
+        if len(lang_code) <= LANGUAGE_CODE_MAX_LENGTH:
+            return _get_supported_language_variant(lang_code, strict=strict)
+        if (
+            not strict
+            and (index := lang_code.rfind("-", 0, LANGUAGE_CODE_MAX_LENGTH)) > 0
+        ):
+            # There is a generic variant under the maximum accepted length.
+            lang_code = lang_code[:index]
+            return _get_supported_language_variant(lang_code, strict=strict)
     raise LookupError(lang_code)
 
 
diff --git a/tests/i18n/tests.py b/tests/i18n/tests.py
index 9334660820f7..7052b9497189 100644
--- a/tests/i18n/tests.py
+++ b/tests/i18n/tests.py
@@ -1730,6 +1730,45 @@ def test_get_supported_language_variant_real(self):
             g("en-" * 167, strict=True)
         self.assertEqual(g("en-" * 30000), "en")  # catastrophic test
 
+    def 
test_get_supported_language_variant_strict_long_lang_code_not_cached(self):
+        g = trans_real.get_supported_language_variant
+        g_cached = trans_real._get_supported_language_variant
+        self.addCleanup(g_cached.cache_clear)
+
+        for lang_code in (
+            "e" * 501,
+            "en-" * 167,  # Length 501 which is LANGUAGE_CODE_MAX_LENGTH + 1.
+            "en-" * 30000,
+        ):
+            g_cached.cache_clear()
+            with self.subTest(length=len(lang_code)):
+                with self.assertRaises(LookupError):
+                    g(lang_code, strict=True)
+
+                cache_info = g_cached.cache_info()
+                self.assertEqual(cache_info.currsize, 0)
+                self.assertEqual(cache_info.misses, 0)
+
+    def test_get_supported_language_variant_truncated_cache_key(self):
+        g = trans_real.get_supported_language_variant
+        g_cached = trans_real._get_supported_language_variant
+        self.addCleanup(g_cached.cache_clear)
+        g_cached.cache_clear()
+
+        long_code = "en-" * 167  # Length 501.
+        truncated_code = "en-" * 165 + "en"  # Length 497.
+        catastrophic_long_code = "en-" * 3000
+
+        for hits, code in enumerate(
+            [long_code, truncated_code, catastrophic_long_code]
+        ):
+            with self.subTest(code_length=len(code)):
+                self.assertEqual(g(code), "en")
+                cache_info = g_cached.cache_info()
+                self.assertEqual(cache_info.currsize, 1)
+                self.assertEqual(cache_info.hits, hits)
+                self.assertEqual(cache_info.misses, 1)
+
     def test_get_supported_language_variant_null(self):
         g = trans_null.get_supported_language_variant
         self.assertEqual(g(settings.LANGUAGE_CODE), settings.LANGUAGE_CODE)

++++++ CVE-2026-84429.patch ++++++
>From 6ecd66e09a383be004a78a3a738ea9727ff07b0e Mon Sep 17 00:00:00 2001
From: Natalia <[email protected]>
Date: Tue, 1 Sep 2026 15:17:57 -0300
Subject: [PATCH] [5.2.x] Fixed CVE-2026-84429 -- Simplified
 parse_header_parameters by leveraging stdlib's Message.

The `parse_header_parameters` function historically used Python's `cgi`
module (now deprecated). In 34e2148fc725e7200050f74130d7523e3cd8507a,
the logic was inlined to work around this deprecation (#33173). Later,
in d4d5427571b4bf3a21c902276c2a00215c2a37cc, the header parsing logic
was further cleaned up to align with multipartparser.py (#33697). It was
then replaced with Python's email.message.Message API in #35440, and
reverted in #36520 over a performance concern on common headers.

But recently, a security report showed that `parse_header_parameters()`
parsed header values in time quadratic in the number of separators
inside a quoted parameter. Because the per-call length limit does not
bound the combined size of a repeated header, an unauthenticated request
could consume excessive CPU, for example via the `Accept` header, which
is parsed by `HttpRequest.accepted_types`.

Therefore, this change restores the `email.message.Message` approach,
which parses in linear time, while keeping the fast path for headers
without parameters and continuing to reject invalid RFC 2231 encodings
(#36991).

Thanks to Raphael Gaschignard for testing, and to Jake Howard,
Adam Johnson, and Shai Berger for reviews. Thanks to Jisung Chae and
Peter Thomassen for the security report.

Co-authored-by: Khudyakov Artem <[email protected]>
Co-authored-by: Ben Cail <[email protected]>

Backport of 7ff7fcc0508864a4bc39693128ace38b1e95a890 from main.
---
 django/utils/http.py           | 67 ++++++++++++++++------------------
 docs/releases/5.2.18.txt       | 19 ++++++++++
 tests/utils_tests/test_http.py | 29 ++++++++++++++-
 3 files changed, 77 insertions(+), 38 deletions(-)

Index: django-4.2.30/django/utils/http.py
===================================================================
--- django-4.2.30.orig/django/utils/http.py
+++ django-4.2.30/django/utils/http.py
@@ -1,10 +1,12 @@
 import base64
+import codecs
 import datetime
 import ipaddress
 import re
 import unicodedata
 from binascii import Error as BinasciiError
-from email.utils import formatdate
+from email.message import Message
+from email.utils import collapse_rfc2231_value, formatdate
 from urllib.parse import (
     ParseResult,
     SplitResult,
@@ -406,46 +408,39 @@ def escape_leading_slashes(url):
     return url
 
 
-def _parseparam(s):
-    while s[:1] == ";":
-        s = s[1:]
-        end = s.find(";")
-        while end > 0 and (s.count('"', 0, end) - s.count('\\"', 0, end)) % 2:
-            end = s.find(";", end + 1)
-        if end < 0:
-            end = len(s)
-        f = s[:end]
-        yield f.strip()
-        s = s[end:]
-
-
 def parse_header_parameters(line):
     """
     Parse a Content-type like header.
     Return the main content-type and a dictionary of options.
     """
-    parts = _parseparam(";" + line)
-    key = parts.__next__().lower()
+    if not line:
+        return "", {}
+
+    # Fast path for no params.
+    if ";" not in line:
+        return line.strip().lower(), {}
+
+    m = Message()
+    m["content-type"] = line
+    params = m.get_params()
+
     pdict = {}
-    for p in parts:
-        i = p.find("=")
-        if i >= 0:
-            has_encoding = False
-            name = p[:i].strip().lower()
-            if name.endswith("*"):
-                # Lang/encoding embedded in the value (like 
"filename*=UTF-8''file.ext")
-                # https://tools.ietf.org/html/rfc2231#section-4
-                name = name[:-1]
-                if p.count("'") == 2:
-                    has_encoding = True
-            value = p[i + 1 :].strip()
-            if len(value) >= 2 and value[0] == value[-1] == '"':
-                value = value[1:-1]
-                value = value.replace("\\\\", "\\").replace('\\"', '"')
-            if has_encoding:
-                encoding, lang, value = value.split("'")
-                value = unquote(value, encoding=encoding)
-            pdict[name] = value
+    key = params.pop(0)[0].lower()
+    for name, value in params:
+        if not name:
+            continue
+        if isinstance(value, tuple):
+            # An RFC 2231 encoded value: (charset, language, value). Reject an
+            # unknown charset rather than let collapse_rfc2231_value() fallback
+            # to us-ascii and silently accept it.
+            if encoding := value[0]:
+                try:
+                    codecs.lookup(encoding)
+                except LookupError:
+                    msg = f"Invalid encoding {encoding!r} for RFC 2231 param."
+                    raise ValueError(msg)
+            value = collapse_rfc2231_value(value)
+        pdict[name] = value
     return key, pdict
 
 
Index: django-4.2.30/tests/utils_tests/test_http.py
===================================================================
--- django-4.2.30.orig/tests/utils_tests/test_http.py
+++ django-4.2.30/tests/utils_tests/test_http.py
@@ -539,13 +539,14 @@ class ParseHeaderParameterTests(unittest
     def test_rfc2231_wrong_title(self):
         """
         Test wrongly formatted RFC 2231 headers (missing double single quotes).
-        Parsing should not crash (#24209).
+        Parsing should not crash (#24209), but stdlib email still decodes the
+        value (#35440).
         """
         test_data = (
             (
                 "Content-Type: application/x-stuff; "
                 "title*='This%20is%20%2A%2A%2Afun%2A%2A%2A",
-                "'This%20is%20%2A%2A%2Afun%2A%2A%2A",
+                "'This is ***fun***",
             ),
             ("Content-Type: application/x-stuff; title*='foo.html", 
"'foo.html"),
             ("Content-Type: application/x-stuff; title*=bar.html", "bar.html"),
@@ -554,6 +555,30 @@ class ParseHeaderParameterTests(unittest
             parsed = parse_header_parameters(raw_line)
             self.assertEqual(parsed[1]["title"], expected_title)
 
+    def test_rfc2231_invalid_encoding(self):
+        test_data = [
+            # Invalid encoding name with percent-encoded value
+            "text/plain; charset*=BOGUS''%20",
+            # Another invalid encoding with different value
+            "text/plain; filename*=INVALID''%s%s%s",
+            # Invalid encoding with multi-line encoded content
+            "text/plain; title*=NOTACODEC''%E2%80%A6",
+        ]
+        msg = "Invalid encoding"
+        for header in test_data:
+            with self.subTest(raw_line=header), 
self.assertRaisesRegex(ValueError, msg):
+                parse_header_parameters(header)
+
+    def test_many_separators_in_quoted_value(self):
+        # A quoted value containing many semicolons is a single parameter, and
+        # trailing unquoted parameters are still parsed.
+        value = ";" * 5000
+        header = 'attachment; filename="%s"; size=1' % value
+        self.assertEqual(
+            parse_header_parameters(header),
+            ("attachment", {"filename": value, "size": "1"}),
+        )
+
 
 class ContentDispositionHeaderTests(unittest.TestCase):
     def test_basic(self):

++++++ CVE-2026-87890.patch ++++++
>From dd0558d1617619e0d66163675ef02135d0f54e5f Mon Sep 17 00:00:00 2001
From: Sarah Boyce <[email protected]>
Date: Thu, 3 Sep 2026 16:23:27 +0200
Subject: [PATCH] [5.2.x] Fixed CVE-2026-87890 -- Blocked bytes in spatial
 lookups.

Even though raster values provided via bytes write to the in-memory
virtual filesystem, that does not prevent network fetches when resolving
references.

Thank you to sicksec for the report, and Jacob Walls, Jake Howard, and
David Smith for reviews.

Backport of ebcb13b327301f28cbc6cd5e4988a719f00575aa from main.
---
 django/contrib/gis/db/models/fields.py   |  8 +++--
 django/contrib/gis/gdal/raster/source.py | 17 +++++++----
 docs/ref/contrib/gis/db-api.txt          |  8 ++---
 docs/ref/contrib/gis/gdal.txt            | 22 ++++++++------
 docs/releases/5.2.18.txt                 | 24 +++++++++++++++
 tests/gis_tests/geoapp/tests.py          | 38 +++++++++++-------------
 6 files changed, 76 insertions(+), 41 deletions(-)

diff --git a/django/contrib/gis/db/models/fields.py 
b/django/contrib/gis/db/models/fields.py
index 5f30e66c0e3e..ae0a24fc9e8f 100644
--- a/django/contrib/gis/db/models/fields.py
+++ b/django/contrib/gis/db/models/fields.py
@@ -220,11 +220,15 @@ def get_prep_value(self, value):
                 try:
                     obj = GEOSGeometry(obj, 
max_geom_collections=max_geom_collections)
                 except (TypeError, ValueError) as err:
-                    if isinstance(obj, str) and 
obj.startswith(VSI_FILESYSTEM_PREFIX):
+                    if isinstance(obj, bytes) or (
+                        isinstance(obj, str) and 
obj.startswith(VSI_FILESYSTEM_PREFIX)
+                    ):
                         raise blocked_err
                     raise err
                 except (GEOSException, GDALException):
-                    if isinstance(obj, str) and json_regex.match(obj):
+                    if isinstance(obj, bytes) or (
+                        isinstance(obj, str) and json_regex.match(obj)
+                    ):
                         raise blocked_err
                     raise ValueError(
                         "Couldn't create spatial object from lookup value 
'%s'." % obj
diff --git a/django/contrib/gis/gdal/raster/source.py 
b/django/contrib/gis/gdal/raster/source.py
index 2bcc035f2076..b6958fd95ecd 100644
--- a/django/contrib/gis/gdal/raster/source.py
+++ b/django/contrib/gis/gdal/raster/source.py
@@ -34,8 +34,8 @@
 
 class DisallowedRasterLookup(SuspiciousOperation):
     """
-    Types that force GDALRaster to open in write mode (dict) or values that
-    could be virtual filesystem paths (str) are not allowed in lookup contexts.
+    Types that force GDALRaster to open in write mode or values that could
+    reference external sources, are not allowed in lookup contexts.
     Instead, wrap values in GDALRaster explicitly.
     """
 
@@ -248,12 +248,19 @@ def check_raster_lookup_value(cls, ds_input):
         """
         Raise DisallowedRasterLookup for values inappropriate in lookups:
         - No dicts, which GDALRaster(write=False) might still write to.
-        - No strings or Paths, which might fetch over the virtual filesystem.
+        - No strings, bytes, or Paths, which might fetch over the virtual
+          filesystem.
         """
         normalized = cls._preprocess_input(ds_input)
-        if isinstance(normalized, (dict, str)):
+        if isinstance(normalized, (dict, str, bytes)):
+            value_repr = repr(normalized)
+            if len(value_repr) > 4096:
+                value_repr = "%s… <trimmed %d bytes string>" % (
+                    value_repr[:4096],
+                    len(value_repr),
+                )
             msg = (
-                f"Cannot use object {normalized!r} for a spatial lookup "
+                f"Cannot use object {value_repr} for a spatial lookup "
                 "parameter. If this is a raster, wrap it with GDALRaster() "
                 "before using it in a lookup to enable writing or fetching."
             )
diff --git a/docs/ref/contrib/gis/db-api.txt b/docs/ref/contrib/gis/db-api.txt
index 9ab40e4f3474..204e96dd6b7e 100644
--- a/docs/ref/contrib/gis/db-api.txt
+++ b/docs/ref/contrib/gis/db-api.txt
@@ -148,10 +148,10 @@ Filters on 'normal' fields (e.g. 
:class:`~django.db.models.CharField`)
 may be chained with those on geographic fields. Geographic lookups accept
 geometry and raster input on both sides, and input types can be mixed freely in
 most cases. However, unlike assignments to model fields, with lookups,
-types such as ``str``, :class:`pathlib.Path`, and ``dict`` must be wrapped by
-:class:`~django.contrib.gis.gdal.GDALRaster` to signify that the potential for
-file writing or network fetching is acceptable. For the rationale, see
-:ref:`raster security considerations <raster-security>`.
+types such as ``bytes``, ``str``, :class:`pathlib.Path`, and ``dict`` must be
+wrapped by :class:`~django.contrib.gis.gdal.GDALRaster` to signify that the
+potential for file writing or network fetching is acceptable. For the
+rationale, see :ref:`raster security considerations <raster-security>`.
 
 The general structure of geographic lookups is described below. A complete
 reference can be found in the :ref:`spatial lookup reference<spatial-lookups>`.
diff --git a/docs/ref/contrib/gis/gdal.txt b/docs/ref/contrib/gis/gdal.txt
index b1147fb3fc81..8d3cf72b5ef4 100644
--- a/docs/ref/contrib/gis/gdal.txt
+++ b/docs/ref/contrib/gis/gdal.txt
@@ -2107,6 +2107,13 @@ Here's how to create a raster and return it as a file in 
an
     ... )
     >>> HttpResponse(rast.vsi_buffer, "image/tiff")
 
+.. warning::
+
+    The memory-based virtual filesystem doesn't make untrusted raster data
+    safe. Raster data may reference local or remote resources, for example
+    through a VRT source, causing GDAL to access files or make network
+    requests. Only instantiate :class:`GDALRaster` from trusted input.
+
 Using other Virtual Filesystems
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
@@ -2168,17 +2175,14 @@ essential to prevent instantiating one from untrusted 
input. Otherwise, an
 attacker might gain the ability to write a file or make a network request.
 
 To mitigate this, :ref:`spatial lookups <spatial-lookups-intro>` prevent
-``str``, :class:`pathlib.Path`, and ``dict`` values from reaching
+``bytes``, ``str``, :class:`pathlib.Path`, and ``dict`` values from reaching
 :class:`GDALRaster` altogether. To use these types with lookups, wrap them
 explicitly with :class:`GDALRaster`, indicating that the value is trusted.
-Bytes are accepted without being wrapped in :class:`GDALRaster` because they
-are opened through GDAL's memory-based :ref:`virtual filesystem
-<gdal-raster-vsimem>`.
-
-This protection applies only to spatial lookups. Assigning a ``dict`` value to
-a :class:`~django.contrib.gis.db.models.RasterField` will still open a new
-raster, and assigning a ``str`` or ``Path`` will still fetch and open the
-referenced raster.
+
+This protection applies only to spatial lookups. Assigning a ``dict`` or
+``bytes`` value to a :class:`~django.contrib.gis.db.models.RasterField` will
+still open a new raster, and assigning a ``str`` or ``Path`` will still fetch
+and open the referenced raster.
 
 When validating geometry inputs, the
 :class:`~django.contrib.gis.forms.GeometryField` form field will reject raster
diff --git a/tests/gis_tests/geoapp/tests.py b/tests/gis_tests/geoapp/tests.py
index a2e66f4b19b5..c22a8e0e0137 100644
--- a/tests/gis_tests/geoapp/tests.py
+++ b/tests/gis_tests/geoapp/tests.py
@@ -694,10 +694,9 @@ def test_lookup_rejects_writing_or_fetching_rasters(self):
         - strings matching a json regex
         - bytes
 
-        Since this could be unexpected in a lookup context, disallow dicts
-        and strings: instead, explicitly wrap with GDALRaster() to signal that
-        a write or fetch is expected. Bytes only write to the in-memory virtual
-        filesystem, so allow them.
+        Since this could be unexpected in a lookup context, disallow dicts,
+        bytes, and strings: instead, explicitly wrap with GDALRaster() to
+        signal that a write or fetch is expected.
 
         Disallowing strings also disallows paths to local or network rasters,
         but those didn't work in the lookup context anyway, since they were
@@ -735,23 +734,18 @@ def test_lookup_rejects_writing_or_fetching_rasters(self):
             "/vsis3/someurl",
             vsimem_path,
             existing_path,
+            existing_path.read_bytes(),
+            b"POINT (invalid)",
         ]
         for obj in disallowed_cases:
-            try:
-                msg_obj = json.loads(obj)
-            except Exception:
-                if isinstance(obj, Path):
-                    msg_obj = str(obj)
-                else:
-                    msg_obj = obj
             msg = (
-                f"Cannot use object {msg_obj!r} for a spatial lookup 
parameter. "
-                "If this is a raster, wrap it with GDALRaster() before using "
-                "it in a lookup to enable writing or fetching."
+                r"Cannot use object .* for a spatial lookup parameter\. If "
+                r"this is a raster, wrap it with GDALRaster\(\) before using "
+                r"it in a lookup to enable writing or fetching\."
             )
             with (
                 self.subTest(obj=obj),
-                self.assertRaisesMessage(DisallowedRasterLookup, msg),
+                self.assertRaisesRegex(DisallowedRasterLookup, msg),
             ):
                 City.objects.filter(point__contained=obj)
 
@@ -761,12 +755,14 @@ def test_lookup_rejects_writing_or_fetching_rasters(self):
             with self.subTest(obj=obj), self.assertRaisesMessage(ValueError, 
msg):
                 City.objects.filter(point__contained=obj)
 
-    def test_lookup_allows_writing_raster_from_bytes(self):
-        raster_path = Path(__file__).parent.parent / "data" / "rasters" / 
"raster.tif"
-        with open(raster_path, "rb") as raster_file:
-            raster_bytes = raster_file.read()
-        # Just get SQL to avoid gating on connection.supports_raster.
-        City.objects.filter(point__contained=raster_bytes).query
+    def test_lookup_rejected_value_repr_is_truncated(self):
+        value = b"x" * 5000
+        with self.assertRaises(DisallowedRasterLookup) as ctx:
+            State.objects.filter(poly__intersects=value)
+
+        message = str(ctx.exception)
+        self.assertIn("… <trimmed 5003 bytes string>", message)
+        self.assertNotIn("x" * 4097, message)
 
     def test_lookup_allows_geos_geometry_string(self):
         geojson = json.dumps({"type": "Point", "coordinates": [2, 49]})

++++++ CVE-2026-87975.patch ++++++
>From ff27883ca055aa9ab23015b50674a5b10dfe5a41 Mon Sep 17 00:00:00 2001
From: Jacob Walls <[email protected]>
Date: Wed, 2 Sep 2026 15:49:28 -0400
Subject: [PATCH] [5.2.x] Fixed CVE-2026-87975 -- Prevented forging actions in
 model formsets with editable PKs.

Deleting instances outside a `ModelFormSet`'s queryset was possible by
forging POST data for an editable primary key. In the same way, creating
instances was possible for edit-only model formsets.

An object outside the queryset is represented by a newly constructed
instance, but its PK can still be populated: `BaseInlineFormSet` sets
the parent link when it's also the primary key, and
`construct_instance()` sets any editable primary PK present on the form.
`save_existing_objects()` then took the populated primary key as proof
that the instance came from the queryset and deleted or persisted the
row.

This change now checks `_state.adding` to identify instances that did
not come from the queryset, whichever way the primary key was populated.
Models using the default `AutoField` primary key are not affected.

Thanks Seonggwon Yoon for the report and Natalia Bidart for reviews.

Backport of 83cbd21be57483e6b3eb6e4688a561c841aff75e from main.
---
 django/forms/models.py        |  9 +++---
 docs/releases/5.2.18.txt      | 14 +++++++++
 tests/model_formsets/tests.py | 59 +++++++++++++++++++++++++++++++++++
 3 files changed, 78 insertions(+), 4 deletions(-)

Index: Django-4.2.11/django/forms/models.py
===================================================================
--- Django-4.2.11.orig/django/forms/models.py
+++ Django-4.2.11/django/forms/models.py
@@ -916,11 +916,12 @@ class BaseModelFormSet(BaseFormSet, Alte
         forms_to_delete = self.deleted_forms
         for form in self.initial_forms:
             obj = form.instance
-            # If the pk is None, it means either:
-            # 1. The object is an unexpected empty model, created by invalid
-            #    POST data such as an object outside the formset's queryset.
+            # If the pk is unset or the instance is being added, it could mean:
+            # 1. The object is an unexpected unfetched instance, created by
+            #    invalid POST data such as an object outside the formset's
+            #    queryset, or a formset with edit_only=True.
             # 2. The object was already deleted from the database.
-            if obj.pk is None:
+            if obj.pk is None or obj._state.adding:
                 continue
             if form in forms_to_delete:
                 self.deleted_objects.append(obj)
Index: Django-4.2.11/tests/model_formsets/tests.py
===================================================================
--- Django-4.2.11.orig/tests/model_formsets/tests.py
+++ Django-4.2.11/tests/model_formsets/tests.py
@@ -158,6 +158,48 @@ class DeletionTests(TestCase):
         self.assertEqual(poet.poem_set.count(), 1)
         self.assertFalse(Poem.objects.filter(pk=poem.pk + 1).exists())
 
+    def test_deletion_not_allowed_outside_formset_queryset(self):
+        OwnerProfileFormSet = inlineformset_factory(
+            Owner, OwnerProfile, can_delete=True, fields="__all__"
+        )
+        place = Place.objects.create(name="Sal's Place", city="Cairo")
+        owner = Owner.objects.create(name="Sal", place=place)
+        profile = OwnerProfile.objects.create(owner=owner, age=42)
+        limited_qs = OwnerProfile.objects.exclude(pk=profile.pk)
+        data = {
+            "ownerprofile-TOTAL_FORMS": "1",
+            "ownerprofile-INITIAL_FORMS": "1",
+            "ownerprofile-MAX_NUM_FORMS": "0",
+            "ownerprofile-0-owner": str(profile.pk),  # Not in `limited_qs`.
+            "ownerprofile-0-age": str(profile.age),
+            "ownerprofile-0-DELETE": "on",
+        }
+        formset = OwnerProfileFormSet(data, instance=owner, 
queryset=limited_qs)
+        self.assertIs(formset.is_valid(), True)
+        formset.save()
+        # The deletion is not allowed.
+        self.assertIs(OwnerProfile.objects.filter(pk=profile.pk).exists(), 
True)
+
+    def test_deletion_not_allowed_outside_formset_queryset_editable_pk(self):
+        CustomPrimaryKeyFormSet = modelformset_factory(
+            CustomPrimaryKey, can_delete=True, fields="__all__"
+        )
+        obj = CustomPrimaryKey.objects.create(my_pk="pk", some_field="data")
+        limited_qs = CustomPrimaryKey.objects.exclude(pk=obj.pk)
+        data = {
+            "form-TOTAL_FORMS": "1",
+            "form-INITIAL_FORMS": "1",
+            "form-MAX_NUM_FORMS": "0",
+            "form-0-my_pk": obj.pk,  # Not in `limited_qs`.
+            "form-0-some_field": obj.some_field,
+            "form-0-DELETE": "on",
+        }
+        formset = CustomPrimaryKeyFormSet(data, queryset=limited_qs)
+        self.assertIs(formset.is_valid(), True)
+        formset.save()
+        # The deletion is not allowed.
+        self.assertIs(CustomPrimaryKey.objects.filter(pk=obj.pk).exists(), 
True)
+
 
 class ModelFormsetTest(TestCase):
     def test_modelformset_factory_without_fields(self):
@@ -2067,6 +2109,23 @@ class ModelFormsetTest(TestCase):
         formset.save()
         self.assertCountEqual(Author.objects.all(), [charles, walt])
 
+    def test_edit_only_editable_pk(self):
+        CustomPrimaryKeyFormSet = modelformset_factory(
+            CustomPrimaryKey, fields="__all__"
+        )
+        data = {
+            "form-TOTAL_FORMS": "1",
+            "form-INITIAL_FORMS": "1",
+            "form-MAX_NUM_FORMS": "0",
+            "form-0-my_pk": "forged",
+            "form-0-some_field": "value",
+        }
+        formset = CustomPrimaryKeyFormSet(data)
+        self.assertIs(formset.is_valid(), True)
+        formset.save()
+        with self.assertRaises(CustomPrimaryKey.DoesNotExist):
+            CustomPrimaryKey.objects.get(pk="forged")
+
     def test_edit_only_formset_factory_with_basemodelformset(self):
         charles = Author.objects.create(name="Charles Baudelaire")
 

Reply via email to