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-11-09 17:45:27
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-Django4 (Old)
 and      /work/SRC/openSUSE:Factory/.python-Django4.new.3759290 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-Django4"

Mon Nov  9 17:45:27 2026 rev:10 rq:1396353 version:4.2.30

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-Django4/python-Django4.changes    
2026-10-08 13:28:54.095593097 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-Django4.new.3759290/python-Django4.changes   
    2026-11-09 17:45:30.041260122 +0100
@@ -1,0 +2,20 @@
+Mon Nov  9 14:12:51 UTC 2026 - Markéta Machová <[email protected]>
+
+- Add security patches:
+  * CVE-2026-108147: Potential memory exhaustion in MultiPartParser
+    via base64-encoded file upload (bsc#1289340)
+    * CVE-2026-108147.patch
+  * CVE-2026-111211: Potential log injection via unescaped geometry
+    form values (bsc#1289344)
+    * CVE-2026-111211.patch
+  * CVE-2026-111362: Server-side request forgery via crs member of
+    GeoJSON geometries (bsc#1289345)
+    * CVE-2026-111362.patch
+  * CVE-2026-113593: Potential exposure of private data via empty
+    and missing Vary headers (bsc#1289348)
+    * CVE-2026-113593.patch
+  * CVE-2026-114582: Server-side request forgery via SpatialReference
+    string input (bsc#1289350)
+    * CVE-2026-114582.patch
+
+-------------------------------------------------------------------

New:
----
  CVE-2026-108147.patch
  CVE-2026-111211.patch
  CVE-2026-111362.patch
  CVE-2026-113593.patch
  CVE-2026-114582.patch

----------(New B)----------
  New:    via base64-encoded file upload (bsc#1289340)
    * CVE-2026-108147.patch
  * CVE-2026-111211: Potential log injection via unescaped geometry
  New:    form values (bsc#1289344)
    * CVE-2026-111211.patch
  * CVE-2026-111362: Server-side request forgery via crs member of
  New:    GeoJSON geometries (bsc#1289345)
    * CVE-2026-111362.patch
  * CVE-2026-113593: Potential exposure of private data via empty
  New:    and missing Vary headers (bsc#1289348)
    * CVE-2026-113593.patch
  * CVE-2026-114582: Server-side request forgery via SpatialReference
  New:    string input (bsc#1289350)
    * CVE-2026-114582.patch
----------(New E)----------

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

Other differences:
------------------
++++++ python-Django4.spec ++++++
--- /var/tmp/diff_new_pack.GdbTrZ/_old  2026-11-09 17:45:31.401316816 +0100
+++ /var/tmp/diff_new_pack.GdbTrZ/_new  2026-11-09 17:45:31.403316899 +0100
@@ -96,6 +96,16 @@
 Patch25:        CVE-2026-87890.patch
 # PATCH-FIX-UPSTREAM CVE-2026-87975.patch bsc#1284471
 Patch26:        CVE-2026-87975.patch
+# PATCH-FIX-UPSTREAM CVE-2026-108147.patch bsc#1289340
+Patch27:        CVE-2026-108147.patch
+# PATCH-FIX-UPSTREAM CVE-2026-111211.patch bsc#1289344
+Patch28:        CVE-2026-111211.patch
+# PATCH-FIX-UPSTREAM CVE-2026-111362.patch bsc#1289345
+Patch29:        CVE-2026-111362.patch
+# PATCH-FIX-UPSTREAM CVE-2026-113593.patch bsc#1289348
+Patch30:        CVE-2026-113593.patch
+# PATCH-FIX-UPSTREAM CVE-2026-114582.patch bsc#1289350
+Patch31:        CVE-2026-114582.patch
 BuildRequires:  %{python_module Jinja2 >= 2.9.2}
 BuildRequires:  %{python_module Pillow >= 6.2.0}
 BuildRequires:  %{python_module PyYAML}

++++++ CVE-2026-108147.patch ++++++
>From 4bd1f59a80adafddbd47527c66d92d6a224244d8 Mon Sep 17 00:00:00 2001
From: Natalia <[email protected]>
Date: Thu, 8 Oct 2026 22:22:41 -0300
Subject: [PATCH] [5.2.x] Fixed CVE-2026-108147 -- Bounded memory use of base64
 file uploads in MultiPartParser.

The mitigation for CVE-2026-33033 aligned base64 data to a multiple of
4 by reading whole chunks. As the chunk size is itself a multiple of 4,
those reads never restored the alignment, so a misaligned upload was
read and decoded in full before reaching upload handlers.

Trailing undecoded bytes are now carried over to the next chunk, so no
extra reads are needed and memory use stays bounded by the chunk size.

Thanks Ignacy Kujawa for the report, and Jake Howard for the review.

Backport of 5d95d9236fa4ededfb1931c60a65432385ece85f from main.
---
 django/http/multipartparser.py |  34 +++++-----
 docs/releases/5.2.19.txt       |  11 ++++
 tests/file_uploads/tests.py    |   2 +-
 tests/requests_tests/tests.py  | 111 ++++++++++++++++++++++++++++-----
 4 files changed, 127 insertions(+), 31 deletions(-)

Index: django-4.2.30/django/http/multipartparser.py
===================================================================
--- django-4.2.30.orig/django/http/multipartparser.py
+++ django-4.2.30/django/http/multipartparser.py
@@ -296,27 +296,27 @@ class MultiPartParser:
                             except StopFutureHandlers:
                                 break
 
+                        base64_remainder = b""
                         for chunk in field_stream:
                             if transfer_encoding == "base64":
                                 # We only special-case base64 transfer encoding
                                 # We should always decode base64 chunks by
-                                # multiple of 4, ignoring whitespace.
-
-                                stripped_parts = [b"".join(chunk.split())]
-                                stripped_length = len(stripped_parts[0])
-
-                                while stripped_length % 4 != 0:
-                                    over_chunk = 
field_stream.read(self._chunk_size)
-                                    if not over_chunk:
-                                        break
-                                    over_stripped = 
b"".join(over_chunk.split())
-                                    stripped_parts.append(over_stripped)
-                                    stripped_length += len(over_stripped)
-
-                                stripped_chunk = b"".join(stripped_parts)
+                                # multiple of 4, ignoring whitespace. Carry the
+                                # trailing 1-3 bytes over to the next chunk
+                                # rather than reading ahead, so that memory use
+                                # stays bounded by the chunk size.
+                                stripped_chunk = base64_remainder + b"".join(
+                                    chunk.split()
+                                )
+                                aligned_length = len(stripped_chunk) // 4 * 4
+                                base64_remainder = 
stripped_chunk[aligned_length:]
+                                if not aligned_length:
+                                    continue
 
                                 try:
-                                    chunk = base64.b64decode(stripped_chunk)
+                                    chunk = base64.b64decode(
+                                        stripped_chunk[:aligned_length]
+                                    )
                                 except Exception as exc:
                                     # Since this is only a chunk, any error is
                                     # an unfixable error.
@@ -333,6 +333,10 @@ class MultiPartParser:
                                     # the handler is None.
                                     break
 
+                        if base64_remainder:
+                            # Leftover bytes mean the base64 data is truncated.
+                            raise MultiPartParserError("Could not decode 
base64 data.")
+
                     except SkipFile:
                         self._close_files()
                         # Just use up the rest of this file...
Index: django-4.2.30/tests/file_uploads/tests.py
===================================================================
--- django-4.2.30.orig/tests/file_uploads/tests.py
+++ django-4.2.30/tests/file_uploads/tests.py
@@ -179,7 +179,7 @@ class FileUploadTests(TestCase):
             "wsgi.input": payload,
         }
         response = self.client.request(**r)
-        self.assertEqual(response.json()["file"], "")
+        self.assertEqual(response.status_code, 400)
 
     def test_unicode_file_name(self):
         with sys_tempfile.TemporaryDirectory() as temp_dir:
Index: django-4.2.30/tests/requests_tests/tests.py
===================================================================
--- django-4.2.30.orig/tests/requests_tests/tests.py
+++ django-4.2.30/tests/requests_tests/tests.py
@@ -1,3 +1,4 @@
+import base64
 import copy
 from io import BytesIO
 from itertools import chain
@@ -5,7 +6,7 @@ from unittest import mock
 from urllib.parse import urlencode
 
 from django.core.exceptions import DisallowedHost
-from django.core.files.uploadhandler import MemoryFileUploadHandler
+from django.core.files.uploadhandler import FileUploadHandler, 
MemoryFileUploadHandler
 from django.core.handlers.wsgi import LimitedStream, WSGIRequest
 from django.http import (
     HttpHeaders,
@@ -628,25 +629,105 @@ class RequestsTests(SimpleTestCase):
         self.assertEqual(len(files), 1)
         self.assertEqual(files["file"].read(), b"\x00\x00\x00")
 
-        # The alignment loop must read in `chunk-sized` units rather than one
-        # byte at a time, otherwise each whitespace byte triggers a separate
-        # read() call with a costly internal unget() cycle.
-        # Parsing this payload should issue exactly 8 LazyStream.read() calls:
+        # The base64 alignment must not issue extra reads, otherwise each
+        # whitespace byte could trigger a separate read() call with a costly
+        # internal unget() cycle. Bytes needed to complete the 4-byte base64
+        # group that spans the chunk boundary are carried over to the next
+        # chunk instead. Parsing this payload should issue exactly 7
+        # LazyStream.read() calls:
         # 1. main_stream.read(1)     -- BoundaryIter.__init__ probe, preamble
         # 2. sub_stream.read(1024)   -- parse_boundary_stream, preamble headers
         # 3. main_stream.read(1)     -- BoundaryIter.__init__ probe, file field
         # 4. field_stream.read(1024) -- parse_boundary_stream, file headers
-        # 5. field_stream.read(65536)-- base64 alignment loop: one chunk-sized
-        #                               read to find the non-whitespace bytes
-        #                               needed to complete the 4-byte base64
-        #                               group that spans the chunk boundary
-        # 6. main_stream.read(1)     -- BoundaryIter.__init__ probe, epilogue
-        # 7. sub_stream.read(1024)   -- parse_boundary_stream, epilogue headers
-        # 8. main_stream.read(1)     -- BoundaryIter.__init__ probe, exhausted
+        # 5. main_stream.read(1)     -- BoundaryIter.__init__ probe, epilogue
+        # 6. sub_stream.read(1024)   -- parse_boundary_stream, epilogue headers
+        # 7. main_stream.read(1)     -- BoundaryIter.__init__ probe, exhausted
         #                               stream; returns b"" and stops iteration
-        # A byte-at-a-time implementation of read() in step 5 would do instead
-        # one read(1) per whitespace byte past the chunk boundary (4488 calls).
-        self.assertEqual(reads, [1, 1024, 1, 1024, 65536, 1, 1024, 1])
+        # A byte-at-a-time alignment would instead do one read(1) per
+        # whitespace byte past the chunk boundary (4488 calls).
+        self.assertEqual(reads, [1, 1024, 1, 1024, 1, 1024, 1])
+
+    def test_multipart_file_upload_base64_streamed_in_chunks(self):
+        # Decoded data is passed to upload handlers in bounded chunks whatever
+        # the alignment of the base64 data relative to the chunk boundaries.
+        # The filename length shifts that alignment.
+        content = bytes(range(256)) * 1024
+        encoded = base64.b64encode(content)
+        chunk_size = 2**16
+
+        class RecordingUploadHandler(FileUploadHandler):
+            def __init__(self, *args, **kwargs):
+                super().__init__(*args, **kwargs)
+                self.chunks = []
+
+            def receive_data_chunk(self, raw_data, start):
+                self.chunks.append(raw_data)
+
+            def file_complete(self, file_size):
+                return None
+
+        for filename in ["a", "ab", "abc", "abcd"]:
+            with self.subTest(filename=filename):
+                payload = FakePayload(
+                    "\r\n".join(
+                        [
+                            f"--{BOUNDARY}",
+                            'Content-Disposition: form-data; name="file"; '
+                            f'filename="{filename}"',
+                            "Content-Type: application/octet-stream",
+                            "Content-Transfer-Encoding: base64",
+                            "",
+                            "",
+                        ]
+                    )
+                )
+                payload.write(encoded + b"\r\n")
+                payload.write(f"--{BOUNDARY}--\r\n")
+                request = WSGIRequest(
+                    {
+                        "REQUEST_METHOD": "POST",
+                        "CONTENT_TYPE": MULTIPART_CONTENT,
+                        "CONTENT_LENGTH": len(payload),
+                        "wsgi.input": payload,
+                    }
+                )
+                handler = RecordingUploadHandler()
+                request.upload_handlers = [handler]
+                request.FILES  # evaluate
+
+                self.assertEqual(b"".join(handler.chunks), content)
+                self.assertGreater(len(handler.chunks), 1)
+                self.assertLessEqual(max(map(len, handler.chunks)), chunk_size)
+
+    def test_multipart_file_upload_base64_truncated(self):
+        for data in ["A", "AA", "AAA", "AAAAA"]:
+            with self.subTest(data=data):
+                payload = FakePayload(
+                    "\r\n".join(
+                        [
+                            f"--{BOUNDARY}",
+                            'Content-Disposition: form-data; name="file"; '
+                            'filename="test.txt"',
+                            "Content-Type: application/octet-stream",
+                            "Content-Transfer-Encoding: base64",
+                            "",
+                            data,
+                            f"--{BOUNDARY}--",
+                            "",
+                        ]
+                    )
+                )
+                request = WSGIRequest(
+                    {
+                        "REQUEST_METHOD": "POST",
+                        "CONTENT_TYPE": MULTIPART_CONTENT,
+                        "CONTENT_LENGTH": len(payload),
+                        "wsgi.input": payload,
+                    }
+                )
+                msg = "Could not decode base64 data."
+                with self.assertRaisesMessage(MultiPartParserError, msg):
+                    request.FILES
 
     def test_POST_after_body_read_and_stream_read_multipart(self):
         """

++++++ CVE-2026-111211.patch ++++++
>From b83bf870ee0006ab0dd84a01d1f3b0391320a51a Mon Sep 17 00:00:00 2001
From: David Smith <[email protected]>
Date: Tue, 6 Oct 2026 06:56:10 +0100
Subject: [PATCH] [5.2.x] Fixed CVE-2026-111211 -- Escaped log messages in
 BaseGeometryWidget.deserialize().

Suitably crafted values submitted to a GeometryField may have allowed
log injection, potentially corrupting log files, obscuring other
attacks, misleading log post-processing tools, or forging log entries.

To mitigate this, invalid values logged on deserialization errors are
now escaped using "unicode_escape" encoding, and trimmed to 4096
characters to avoid flooding logs with large inputs.

Thanks to Aaron chen for the report, and to Jacob Walls, Jake Howard,
and Shai Berger for the reviews.

Co-authored-by: Natalia <[email protected]>

Backport of af4319f7217e33ad5db747226765f09b49ec07ba from main.
---
 django/contrib/gis/forms/widgets.py | 12 ++++++++-
 django/utils/log.py                 | 40 +++++++++++++++++++++++++++++
 django/utils/text.py                | 17 ++++++++++++
 docs/releases/5.2.19.txt            | 13 ++++++++++
 tests/gis_tests/test_geoforms.py    | 33 ++++++++++++++++++++++++
 5 files changed, 114 insertions(+), 1 deletion(-)

Index: django-4.2.30/django/contrib/gis/forms/widgets.py
===================================================================
--- django-4.2.30.orig/django/contrib/gis/forms/widgets.py
+++ django-4.2.30/django/contrib/gis/forms/widgets.py
@@ -9,6 +9,8 @@ from django.contrib.gis.geos.prototypes.
 from django.forms.widgets import Widget
 from django.utils import translation
 from django.utils.deprecation import RemovedInDjango51Warning
+from django.utils.log import log_message
+from django.utils.text import trim_value
 
 logger = logging.getLogger("django.contrib.gis")
 
@@ -54,7 +56,15 @@ class BaseGeometryWidget(Widget):
         try:
             return GEOSGeometry(value, 
max_geom_collections=self.max_geom_collections)
         except (GEOSException, ValueError, TypeError) as err:
-            logger.error("Error creating geometry from value '%s' (%s)", 
value, err)
+            # Trim large values, keeping the suffix out of the escaped args.
+            trimmed, suffix = trim_value(value)
+            log_message(
+                logger,
+                "Error creating geometry from value '%s" + suffix + "' (%s)",
+                trimmed,
+                err,
+                level="error",
+            )
         return None
 
     def get_context(self, name, value, attrs):
Index: django-4.2.30/django/utils/log.py
===================================================================
--- django-4.2.30.orig/django/utils/log.py
+++ django-4.2.30/django/utils/log.py
@@ -207,6 +207,46 @@ class ServerFormatter(logging.Formatter)
         return self._fmt.find("{server_time}") >= 0
 
 
+def log_message(
+    logger,
+    message,
+    *args,
+    level=None,
+    status_code=None,
+    request=None,
+    exception=None,
+    **extra,
+):
+    """Log `message` using `logger` based on `status_code` and logger `level`.
+
+    Pass `request`, `status_code` (if defined) and any provided `extra` as such
+    to the logging method,
+
+    Arguments from `args` will be escaped to avoid potential log injections.
+
+    """
+    extra = {"request": request, **extra}
+    if status_code is not None:
+        extra["status_code"] = status_code
+        if level is None:
+            if status_code >= 500:
+                level = "error"
+            elif status_code >= 400:
+                level = "warning"
+
+    escaped_args = tuple(
+        a.encode("unicode_escape").decode("ascii") if isinstance(a, str) else a
+        for a in args
+    )
+
+    getattr(logger, level or "info")(
+        message,
+        *escaped_args,
+        extra=extra,
+        exc_info=exception,
+    )
+
+
 def log_response(
     message,
     *args,
Index: django-4.2.30/django/utils/text.py
===================================================================
--- django-4.2.30.orig/django/utils/text.py
+++ django-4.2.30/django/utils/text.py
@@ -298,6 +298,23 @@ class Truncator(SimpleLazyObject):
         return out
 
 
+def trim_value(value, limit=4096):
+    """Trim string `value` to `limit` characters.
+
+    Return the trimmed value and suffix noting how many chars were trimmed
+    (empty when no trimming was done).
+
+    Non-string values are returned unchanged and suffix is the empty string.
+    """
+    suffix = ""
+    if isinstance(value, str) and (value_len := len(value)) > limit:
+        value, suffix = (
+            value[:limit],
+            f"… <trimmed {value_len - limit} chars out of {value_len}>",
+        )
+    return value, suffix
+
+
 @keep_lazy_text
 def get_valid_filename(name):
     """
Index: django-4.2.30/tests/gis_tests/test_geoforms.py
===================================================================
--- django-4.2.30.orig/tests/gis_tests/test_geoforms.py
+++ django-4.2.30/tests/gis_tests/test_geoforms.py
@@ -498,6 +498,39 @@ class GeometryWidgetTests(SimpleTestCase
         context = widget.get_context("geometry", None, None)
         self.assertEqual(context["geom_type"], "Geometry")
 
+    def test_deserialize_logs_escaped_value(self):
+        # log_message() is fully covered by logging_tests in
+        # LogResponseRealLoggerTests.test_unicode_escape_escaping().
+        test_cases = [
+            ("line\nbreak", "line\\nbreak"),
+            ("map 🗺️", "map \\U0001f5fa\\ufe0f"),
+            ("escape\x1b[31mred\x1b[0m", "escape\\x1b[31mred\\x1b[0m"),
+        ]
+        for value, escaped_value in test_cases:
+            with (
+                self.subTest(value=value),
+                self.assertLogs("django.contrib.gis", "ERROR") as logs,
+            ):
+                self.assertIsNone(BaseGeometryWidget().deserialize(value))
+                message = logs.records[0].getMessage()
+                self.assertEqual(
+                    message,
+                    f"Error creating geometry from value '{escaped_value}' "
+                    "(String input unrecognized as WKT EWKT, and HEXEWKB.)",
+                )
+
+    def test_deserialize_logs_trimmed_value(self):
+        value = "x" * 5000
+        with self.assertLogs("django.contrib.gis", "ERROR") as logs:
+            self.assertIsNone(BaseGeometryWidget().deserialize(value))
+        self.maxDiff = None
+        self.assertEqual(
+            logs.records[0].getMessage(),
+            "Error creating geometry from value "
+            "'%s… <trimmed 904 chars out of 5000>' "
+            "(String input unrecognized as WKT EWKT, and HEXEWKB.)" % ("x" * 
4096),
+        )
+
     def test_subwidgets(self):
         widget = forms.BaseGeometryWidget()
         self.assertEqual(

++++++ CVE-2026-111362.patch ++++++
++++ 720 lines (skipped)

++++++ CVE-2026-113593.patch ++++++
>From 9007fecd3633cfd90b093ebe106af564347c8aee Mon Sep 17 00:00:00 2001
From: Andrew James Gonzalez <[email protected]>
Date: Wed, 21 Oct 2026 10:00:15 +0200
Subject: [PATCH] [5.2.x] Fixed CVE-2026-113593 -- Differentiated empty and
 missing headers in response cache.

When learning the cache key for a response, a missing header must be
cached differently to an empty one, as per RFC 9111.

Thanks to Andrew Gonzalez for the report.

Co-authored-by: Jake Howard <[email protected]>

Backport of c0b7e5e4e4546e8ddc58262f1945938bb9d9ac2d from main.
---
 django/utils/cache.py    | 10 ++++++++--
 docs/releases/5.2.19.txt | 20 ++++++++++++++++++++
 tests/cache/tests.py     | 19 ++++++++++++++++++-
 3 files changed, 46 insertions(+), 3 deletions(-)

diff --git a/django/utils/cache.py b/django/utils/cache.py
index 253c68778447..31fa67e42860 100644
--- a/django/utils/cache.py
+++ b/django/utils/cache.py
@@ -352,8 +352,14 @@ def _generate_cache_key(request, method, headerlist, 
key_prefix):
     ctx = md5(usedforsecurity=False)
     for header in headerlist:
         value = request.META.get(header)
-        if value is not None:
-            ctx.update(value.encode())
+        if value is None:
+            # An absent field must not share a cache key with a field that is
+            # present but empty (RFC 9111, Section 4.1). Emit a marker that
+            # cannot collide with any netstring.
+            ctx.update(b"-,")
+            continue
+        data = value.encode()
+        ctx.update(data)
     url = md5(request.build_absolute_uri().encode("ascii"), 
usedforsecurity=False)
     cache_key = "views.decorators.cache.cache_page.%s.%s.%s.%s" % (
         key_prefix,
diff --git a/tests/cache/tests.py b/tests/cache/tests.py
index 2fd9bc7137da..b990a9988892 100644
--- a/tests/cache/tests.py
+++ b/tests/cache/tests.py
@@ -2152,7 +2152,24 @@ def test_learn_cache_key(self):
         self.assertEqual(
             get_cache_key(request),
             "views.decorators.cache.cache_page.settingsprefix.GET."
-            
"18a03f9c9649f7d684af5db3524f5c99.d41d8cd98f00b204e9800998ecf8427e",
+            
"18a03f9c9649f7d684af5db3524f5c99.4792b9bac48c3f0441b0d9b0d7af8f2c",
+        )
+
+    def test_cache_key_blank_header(self):
+        """
+        An absent field must be distinguished from a present-but-empty field
+        (RFC 9111, Section 4.1).
+        """
+        request_no_headers = self.factory.head(self.path)
+        request_empty_headers = self.factory.head(self.path, headers={"Pony": 
""})
+        response = HttpResponse()
+        response.headers["Vary"] = "Pony"
+        key_no_headers = learn_cache_key(request_no_headers, response)
+        key_empty_headers = learn_cache_key(request_empty_headers, response)
+        self.assertNotEqual(key_no_headers, key_empty_headers)
+        self.assertNotEqual(
+            get_cache_key(request_no_headers),
+            get_cache_key(request_empty_headers),
         )
 
     def test_patch_cache_control(self):

++++++ CVE-2026-114582.patch ++++++
>From 8bda4d0db15f28828d8db000287562efae5ce063 Mon Sep 17 00:00:00 2001
From: Jacob Walls <[email protected]>
Date: Mon, 19 Oct 2026 21:54:23 -0400
Subject: [PATCH] [5.2.x] Fixed CVE-2026-114582 -- Blocked file and network
 access when setting SRS on GDAL 3.9+.

Anything instantating `SpatialReference`, including vector and raster
`transform()` methods and `srs`/`srid` setters, was subject to request
forgery from untrusted strings, since GDAL resolves file and network
references by default.

On GDAL 3.9+, Django now uses `OSRSetFromUserInputEx()` to block file
and network access in this path. On lower GDAL versions, users should
compare inputs to an allow list or use integers when setting or
transforming a spatial reference system (SRS). A new `gis.W001` check
warns if a lower GDAL version is installed.

Thanks Adraca for the report and Sarah Boyce and David Smith for reviews.

Backport of 3a2ec4378b53c7b8ff5283ad7eba624ffa6f2b2f from main.
---
 django/contrib/gis/apps.py                |  5 ++-
 django/contrib/gis/checks.py              | 17 +++++++
 django/contrib/gis/gdal/prototypes/srs.py |  9 +++-
 django/contrib/gis/gdal/srs.py            | 22 ++++++++-
 django/core/checks/registry.py            |  1 +
 docs/ref/checks.txt                       | 14 ++++++
 docs/ref/contrib/gis/gdal.txt             | 13 ++++++
 docs/releases/5.2.19.txt                  | 35 +++++++++++++++
 tests/gis_tests/gdal_tests/test_srs.py    | 54 +++++++++++++++++++++++
 tests/gis_tests/test_checks.py            | 24 ++++++++++
 10 files changed, 189 insertions(+), 5 deletions(-)
 create mode 100644 django/contrib/gis/checks.py
 create mode 100644 tests/gis_tests/test_checks.py

Index: django-4.2.30/django/contrib/gis/apps.py
===================================================================
--- django-4.2.30.orig/django/contrib/gis/apps.py
+++ django-4.2.30/django/contrib/gis/apps.py
@@ -1,7 +1,9 @@
 from django.apps import AppConfig
-from django.core import serializers
+from django.core import checks, serializers
 from django.utils.translation import gettext_lazy as _
 
+from .checks import check_gdal_39
+
 
 class GISConfig(AppConfig):
     default_auto_field = "django.db.models.AutoField"
@@ -12,3 +14,4 @@ class GISConfig(AppConfig):
         serializers.BUILTIN_SERIALIZERS.setdefault(
             "geojson", "django.contrib.gis.serializers.geojson"
         )
+        checks.register(check_gdal_39, checks.Tags.gis, checks.Tags.security)
Index: django-4.2.30/django/contrib/gis/checks.py
===================================================================
--- /dev/null
+++ django-4.2.30/django/contrib/gis/checks.py
@@ -0,0 +1,17 @@
+from django.contrib.gis.gdal import GDAL_VERSION
+from django.core.checks import Warning
+
+W001 = Warning(
+    "The installed version of GDAL does not allow blocking file and network "
+    "access. Upgrade to GDAL 3.9 or higher to receive protection against "
+    "malicious spatial references.",
+    id="gis.W001",
+    hint=f"Your version: {GDAL_VERSION}",
+)
+
+
+def check_gdal_39(app_configs, **kwargs):
+    errors = []
+    if GDAL_VERSION < (3, 9):
+        errors.append(W001)
+    return errors
Index: django-4.2.30/django/contrib/gis/gdal/prototypes/srs.py
===================================================================
--- django-4.2.30.orig/django/contrib/gis/gdal/prototypes/srs.py
+++ django-4.2.30/django/contrib/gis/gdal/prototypes/srs.py
@@ -52,7 +52,12 @@ from_wkt = void_output(lgdal.OSRImportFr
 from_proj = void_output(lgdal.OSRImportFromProj4, [c_void_p, c_char_p])
 from_epsg = void_output(std_call("OSRImportFromEPSG"), [c_void_p, c_int])
 from_xml = void_output(lgdal.OSRImportFromXML, [c_void_p, c_char_p])
-from_user_input = void_output(std_call("OSRSetFromUserInput"), [c_void_p, 
c_char_p])
+if GDAL_VERSION < (3, 9):
+    from_user_input = void_output(std_call("OSRSetFromUserInput"), [c_void_p, 
c_char_p])
+else:
+    from_user_input_ex = void_output(
+        lgdal.OSRSetFromUserInputEx, [c_void_p, c_char_p, c_void_p]
+    )
 
 # Morphing to/from ESRI WKT.
 morph_to_esri = void_output(lgdal.OSRMorphToESRI, [c_void_p])
Index: django-4.2.30/django/contrib/gis/gdal/srs.py
===================================================================
--- django-4.2.30.orig/django/contrib/gis/gdal/srs.py
+++ django-4.2.30/django/contrib/gis/gdal/srs.py
@@ -30,11 +30,20 @@ from ctypes import byref, c_char_p, c_in
 from enum import IntEnum
 
 from django.contrib.gis.gdal.base import GDALBase
-from django.contrib.gis.gdal.error import SRSException
+from django.contrib.gis.gdal.error import GDALException, SRSException
 from django.contrib.gis.gdal.libgdal import GDAL_VERSION
 from django.contrib.gis.gdal.prototypes import srs as capi
 from django.utils.encoding import force_bytes, force_str
 
+# Create null terminated gdal options array.
+papsz_options = [
+    b"ALLOW_FILE_ACCESS=NO",
+    b"ALLOW_NETWORK_ACCESS=NO",
+    None,
+]
+# Convert papszlist to ctypes array.
+PAPSZ_OPTIONS = (c_char_p * len(papsz_options))(*papsz_options)
+
 
 class AxisOrder(IntEnum):
     TRADITIONAL = 0
@@ -309,7 +318,15 @@ class SpatialReference(GDALBase):
 
     def import_user_input(self, user_input):
         "Import the Spatial Reference from the given user input string."
-        capi.from_user_input(self.ptr, force_bytes(user_input))
+        user_input = force_bytes(user_input)
+        # GDAL's dictionary parser bypasses its file access restrictions.
+        if 
user_input.lstrip().lower().removeprefix(b"esri::").startswith(b"dict:"):
+            msg = "Spatial references from dictionaries are not supported."
+            raise GDALException(msg)
+        if GDAL_VERSION < (3, 9):
+            capi.from_user_input(self.ptr, user_input)
+        else:
+            capi.from_user_input_ex(self.ptr, user_input, byref(PAPSZ_OPTIONS))
 
     def import_wkt(self, wkt):
         "Import the Spatial Reference from OGC WKT (string)"
Index: django-4.2.30/django/core/checks/registry.py
===================================================================
--- django-4.2.30.orig/django/core/checks/registry.py
+++ django-4.2.30/django/core/checks/registry.py
@@ -15,6 +15,7 @@ class Tags:
     compatibility = "compatibility"
     database = "database"
     files = "files"
+    gis = "gis"
     models = "models"
     security = "security"
     signals = "signals"
Index: django-4.2.30/docs/ref/checks.txt
===================================================================
--- django-4.2.30.orig/docs/ref/checks.txt
+++ django-4.2.30/docs/ref/checks.txt
@@ -83,6 +83,7 @@ Django's system checks are organized usi
   you specify configured database aliases using the ``--database`` option when
   calling the :djadmin:`check` command.
 * ``files``: Checks files related configuration.
+* ``gis``: Checks :mod:`django.contrib.gis` configuration.
 * ``models``: Checks of model, field, and manager definitions.
 * ``security``: Checks security related configuration.
 * ``signals``: Checks on signal declarations and handler registrations.
@@ -94,6 +95,10 @@ Django's system checks are organized usi
 
 Some checks may be registered with multiple tags.
 
+.. versionchanged:: 5.2.19
+
+  The ``gis`` tag was added.
+
 Core system checks
 ==================
 
@@ -857,6 +862,15 @@ The following checks are performed when
   ``contenttypes.ContentType``.
 * **contenttypes.E005**: Model names must be at most 100 characters.
 
+``gis``
+-------
+
+The following checks are performed when using :mod:`django.contrib.gis`:
+
+* **gis.W001**: The installed version of GDAL does not allow blocking file and
+  network access. Upgrade to GDAL 3.9 or higher to receive protection against
+  malicious spatial references.
+
 ``postgres``
 ------------
 
Index: django-4.2.30/docs/ref/contrib/gis/gdal.txt
===================================================================
--- django-4.2.30.orig/docs/ref/contrib/gis/gdal.txt
+++ django-4.2.30/docs/ref/contrib/gis/gdal.txt
@@ -1030,6 +1030,19 @@ Coordinate System Objects
         ... AUTHORITY["EPSG","4326"]]"""
         ... )  # OGC WKT
 
+    .. warning::
+
+        **URLs and file paths resolved below GDAL 3.9**
+
+        If a URL or file path is provided to ``SpatialReference``, versions of
+        GDAL below 3.9 will resolve it. Compare user-supplied strings to an
+        allow list first, or use an integer.
+
+    .. versionchanged:: 5.2.19
+
+        Blocking file and network access when forwarding inputs to GDAL 3.9+
+        was added.
+
     .. method:: __getitem__(target)
 
     Returns the value of the given string attribute node, ``None`` if the node
Index: django-4.2.30/tests/gis_tests/gdal_tests/test_srs.py
===================================================================
--- django-4.2.30.orig/tests/gis_tests/gdal_tests/test_srs.py
+++ django-4.2.30/tests/gis_tests/gdal_tests/test_srs.py
@@ -1,3 +1,6 @@
+from os.path import relpath
+from pathlib import Path
+from tempfile import TemporaryDirectory
 from unittest import skipIf
 
 from django.contrib.gis.gdal import (
@@ -401,3 +404,52 @@ class SpatialRefTest(SimpleTestCase):
             if hasattr(s, "epsg"):
                 srs = SpatialReference(s.wkt)
                 self.assertEqual(srs.srid, s.epsg)
+
+    def test_no_dictionary_access(self):
+        # GDAL searches for dictionary files relative to the working directory.
+        tmpdir = self.enterContext(TemporaryDirectory(dir="."))
+        dictionary = Path(relpath(tmpdir)) / "srs.wkt"
+        dictionary.write_text(f"4326,{srlist[0].wkt}\n", encoding="utf-8")
+        msg = "Spatial references from dictionaries are not supported."
+        for prefix in (
+            "DICT:",
+            "dict:",
+            "DiCt:",
+            "ESRI::DICT:",
+            "esri::dict:",
+            "eSrI::DiCt:",
+        ):
+            for whitespace in ("", " ", "\t\r\n\v\f"):
+                user_input = f"{whitespace}{prefix}{dictionary},4326"
+                with (
+                    self.subTest(user_input=user_input),
+                    self.assertRaisesMessage(GDALException, msg),
+                ):
+                    SpatialReference(user_input)
+
+    def test_esri_prefixed_wkt(self):
+        wkt = srlist[0].wkt
+        expected = SpatialReference(wkt)
+        for prefix in ("ESRI::", "esri::", " \teSrI::"):
+            with self.subTest(prefix=prefix):
+                srs = SpatialReference(prefix + wkt)
+                self.assertEqual(srs.wkt, expected.wkt)
+
+    @skipIf(GDAL_VERSION < (3, 9), "OSRSetFromUserInputEx() is new in 3.9.")
+    def test_no_file_access(self):
+        cities_prj = Path(__file__).parent.parent / "data" / "cities" / 
"cities.prj"
+        with (
+            self.assertRaises(GDALException),
+            self.assertLogs("django.contrib.gis", "ERROR") as cm,
+        ):
+            SpatialReference(str(cities_prj))
+        self.assertIn("due to ALLOW_FILE_ACCESS=NO", 
cm.records[0].getMessage())
+
+    @skipIf(GDAL_VERSION < (3, 9), "OSRSetFromUserInputEx() is new in 3.9.")
+    def test_no_network_access(self):
+        with (
+            self.assertRaises(GDALException),
+            self.assertLogs("django.contrib.gis", "ERROR") as cm,
+        ):
+            SpatialReference("https://spatialreference.org/ref/epsg/2796/";)
+        self.assertIn("due to ALLOW_NETWORK_ACCESS=NO", 
cm.records[0].getMessage())
Index: django-4.2.30/tests/gis_tests/test_checks.py
===================================================================
--- /dev/null
+++ django-4.2.30/tests/gis_tests/test_checks.py
@@ -0,0 +1,24 @@
+from unittest import mock
+
+from django.contrib.gis.checks import check_gdal_39
+from django.contrib.gis.gdal import GDAL_VERSION
+from django.core.checks import Warning
+from django.test import SimpleTestCase
+
+
+class GISChecksTests(SimpleTestCase):
+    def test_gdal_39_warning(self):
+        msg = (
+            "The installed version of GDAL does not allow blocking file and "
+            "network access. Upgrade to GDAL 3.9 or higher to receive "
+            "protection against malicious spatial references."
+        )
+        with mock.patch("django.contrib.gis.checks.GDAL_VERSION", (3, 8, 5)):
+            self.assertEqual(
+                check_gdal_39(None),
+                [Warning(msg, hint=f"Your version: {GDAL_VERSION}", 
id="gis.W001")],
+            )
+        with mock.patch("django.contrib.gis.checks.GDAL_VERSION", (3, 9)):
+            self.assertEqual(check_gdal_39(None), [])
+        with mock.patch("django.contrib.gis.checks.GDAL_VERSION", (3, 9, 1)):
+            self.assertEqual(check_gdal_39(None), [])

Reply via email to