new file mode 100644
@@ -0,0 +1,563 @@
+From 1776209a7609053b6afe6cb0f14960c478de5424 Mon Sep 17 00:00:00 2001
+From: Jacob Walls <jacobtylerwalls@gmail.com>
+Date: Thu, 9 Jul 2026 11:07:28 -0400
+Subject: [PATCH] [5.2.x] Fixed CVE-2026-15307 -- Blocked raster strings and
+ dicts in spatial lookups.
+
+Spatial lookups optimistically parse values as rasters before retrying
+as geometries. If a malicious value reached the GDALRaster constructor,
+depending on the raster driver, it might write to disk or fetch from the
+network regardless of the constructor's `write=False` default argument.
+
+Although this works as designed for model field assignment, this is
+potentially unexpected for querying, for example, in the admin's
+changelist view, which allows staff users to execute arbitrary lookups
+on models registered with the admin.
+
+Network rasters didn't even work in lookup contexts before, providing
+further evidence that this use case was unintentional. (The failure
+point was after the fetching, however.)
+
+Now, strings and dicts representing rasters are rejected by spatial
+lookups. To opt in to using them, wrap them in a `GDALRaster` first.
+
+Although it would simplify the implementation to try geometries before
+rasters (instead of stashing a raster exception and raising it later),
+we maintain the current order, which has been stable for a decade.
+
+Thanks Bence Nagy, localhost-detect, and kimchunbok_ for providing
+information useful in evaluating this report. Thanks Simon Charette,
+Natalia Bidart, and Sarah Boyce for reviews.
+
+Backport of f1949c1f9758947ade984c895ff16bef46f56520 from main.
+
+CVE: CVE-2026-15307
+Upstream-Status: Backport [https://github.com/django/django/commit/115ffd0463a765ab1cc93de18e94b5459b8a300e]
+
+Backport Changes:
+- Dropped the file docs/releases/5.2.17.txt as
+ current version for Scarthgap is 5.0.14
+
+(cherry picked from commit 115ffd0463a765ab1cc93de18e94b5459b8a300e)
+Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
+---
+ django/contrib/gis/db/models/fields.py | 37 +++++---
+ django/contrib/gis/gdal/raster/source.py | 46 ++++++++--
+ docs/ref/contrib/gis/db-api.txt | 12 ++-
+ docs/ref/contrib/gis/gdal.txt | 34 +++++++
+ tests/gis_tests/geoadmin/tests.py | 25 ++++-
+ tests/gis_tests/geoapp/tests.py | 91 +++++++++++++++++++
+ tests/gis_tests/rasterapp/test_rasterfield.py | 25 ++---
+ tests/gis_tests/test_geoforms.py | 15 +++
+ 8 files changed, 246 insertions(+), 39 deletions(-)
+
+diff --git a/django/contrib/gis/db/models/fields.py b/django/contrib/gis/db/models/fields.py
+index 889c1cfe84..15d9ae3c57 100644
+--- a/django/contrib/gis/db/models/fields.py
++++ b/django/contrib/gis/db/models/fields.py
+@@ -3,6 +3,9 @@ from collections import defaultdict, namedtuple
+ from django.contrib.gis import forms, gdal
+ from django.contrib.gis.db.models.proxy import SpatialProxy
+ from django.contrib.gis.gdal.error import GDALException
++from django.contrib.gis.gdal.raster.const import VSI_FILESYSTEM_PREFIX
++from django.contrib.gis.gdal.raster.source import DisallowedRasterLookup
++from django.contrib.gis.geometry import json_regex
+ from django.contrib.gis.geos import (
+ GeometryCollection,
+ GEOSException,
+@@ -172,21 +175,19 @@ class BaseSpatialField(Field):
+ def get_raster_prep_value(self, value, is_candidate):
+ """
+ Return a GDALRaster if conversion is successful, otherwise return None.
++
++ Unless the user opts in by wrapping values in a GDALRaster, raise
++ DisallowedRasterLookup for values that fetch or write to disk.
+ """
+ if isinstance(value, gdal.GDALRaster):
+ return value
+- elif is_candidate:
++ gdal.GDALRaster.check_raster_lookup_value(value)
++ if is_candidate:
+ try:
+ return gdal.GDALRaster(value)
+ except GDALException:
+ pass
+- elif isinstance(value, dict):
+- try:
+- return gdal.GDALRaster(value)
+- except GDALException:
+- raise ValueError(
+- "Couldn't create spatial object from lookup value '%s'." % value
+- )
++ return None
+
+ def get_prep_value(self, value):
+ obj = super().get_prep_value(value)
+@@ -202,22 +203,36 @@ class BaseSpatialField(Field):
+ obj, "__geo_interface__"
+ )
+ # Try to convert the input to raster.
+- raster = self.get_raster_prep_value(obj, is_candidate)
+-
++ raster = None
++ blocked_err = None
++ try:
++ raster = self.get_raster_prep_value(obj, is_candidate)
++ except DisallowedRasterLookup as err:
++ if isinstance(obj, dict):
++ raise err
++ # Don't immediately raise in case this is a valid GEOSGeometry.
++ blocked_err = err
+ if raster:
+ obj = raster
+ elif is_candidate:
+ try:
+ obj = GEOSGeometry(obj)
++ except (TypeError, ValueError) as err:
++ if 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):
++ raise blocked_err
+ raise ValueError(
+ "Couldn't create spatial object from lookup value '%s'." % obj
+ )
+ else:
+- raise ValueError(
++ msg = (
+ "Cannot use object with type %s for a spatial lookup parameter."
+ % type(obj).__name__
+ )
++ raise blocked_err or ValueError(msg)
+
+ # Assigning the SRID value.
+ obj.srid = self.get_srid(obj)
+diff --git a/django/contrib/gis/gdal/raster/source.py b/django/contrib/gis/gdal/raster/source.py
+index b33eb11c0f..f63e7d0f30 100644
+--- a/django/contrib/gis/gdal/raster/source.py
++++ b/django/contrib/gis/gdal/raster/source.py
+@@ -28,10 +28,19 @@ from django.contrib.gis.gdal.raster.const import (
+ )
+ from django.contrib.gis.gdal.srs import SpatialReference, SRSException
+ from django.contrib.gis.geometry import json_regex
++from django.core.exceptions import SuspiciousOperation
+ from django.utils.encoding import force_bytes, force_str
+ from django.utils.functional import cached_property
+
+
++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.
++ Instead, wrap values in GDALRaster explicitly.
++ """
++
++
+ class TransformPoint(list):
+ indices = {
+ "origin": (0, 3),
+@@ -78,14 +87,10 @@ class GDALRaster(GDALRasterBase):
+ self._write = 1 if write else 0
+ Driver.ensure_registered()
+
+- # Preprocess json inputs. This converts json strings to dictionaries,
+- # which are parsed below the same way as direct dictionary inputs.
+- if isinstance(ds_input, str) and json_regex.match(ds_input):
+- ds_input = json.loads(ds_input)
++ ds_input = self._preprocess_input(ds_input)
+
+ # If input is a valid file path, try setting file as source.
+- if isinstance(ds_input, (str, Path)):
+- ds_input = str(ds_input)
++ if isinstance(ds_input, str):
+ if not ds_input.startswith(VSI_FILESYSTEM_PREFIX) and not os.path.exists(
+ ds_input
+ ):
+@@ -226,6 +231,35 @@ class GDALRaster(GDALRasterBase):
+ """
+ return "<Raster object at %s>" % hex(addressof(self._ptr))
+
++ @classmethod
++ def _preprocess_input(cls, ds_input):
++ """
++ Preprocess json and Path inputs. This converts json strings to
++ dictionaries, which are then parsed just like direct dictionary inputs.
++ This also stringifies Path objects.
++ """
++ if isinstance(ds_input, str) and json_regex.match(ds_input):
++ ds_input = json.loads(ds_input)
++ if isinstance(ds_input, Path):
++ ds_input = str(ds_input)
++ return ds_input
++
++ @classmethod
++ 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.
++ """
++ normalized = cls._preprocess_input(ds_input)
++ if isinstance(normalized, (dict, str)):
++ msg = (
++ f"Cannot use object {normalized!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."
++ )
++ raise DisallowedRasterLookup(msg)
++
+ def _flush(self):
+ """
+ Flush all data from memory into the source file if it exists.
+diff --git a/docs/ref/contrib/gis/db-api.txt b/docs/ref/contrib/gis/db-api.txt
+index df1d3847e6..51dece9b63 100644
+--- a/docs/ref/contrib/gis/db-api.txt
++++ b/docs/ref/contrib/gis/db-api.txt
+@@ -146,11 +146,21 @@ GeoDjango are only available on spatial fields.
+
+ 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.
++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>`.
+
+ The general structure of geographic lookups is described below. A complete
+ reference can be found in the :ref:`spatial lookup reference<spatial-lookups>`.
+
++.. versionchanged:: 5.2.17
++
++ In earlier versions, spatial lookups accepted ``str`` and ``dict`` types
++ for new rasters, allowing file writes and network fetches.
++
+ Geometry Lookups
+ ----------------
+
+diff --git a/docs/ref/contrib/gis/gdal.txt b/docs/ref/contrib/gis/gdal.txt
+index 9011aa6e2b..2df807ee74 100644
+--- a/docs/ref/contrib/gis/gdal.txt
++++ b/docs/ref/contrib/gis/gdal.txt
+@@ -2068,6 +2068,40 @@ previously configured for authentication and possibly other settings (see the
+
+ .. _`GDAL Virtual Filesystems documentation`: https://gdal.org/user/virtual_file_systems.html
+
++.. _raster-security:
++
++Security considerations
++~~~~~~~~~~~~~~~~~~~~~~~
++
++Since :class:`GDALRaster` always opens new rasters in write mode, it is
++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
++: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.
++
++When validating geometry inputs, the
++:class:`~django.contrib.gis.forms.GeometryField` form field will reject raster
++values. When validating raster inputs, you should write custom validation.
++
++For defense-in-depth strategies for limiting the available raster drivers, see
++`GDAL security considerations <https://gdal.org/user/security.html>`_.
++
++.. versionchanged:: 5.2.17
++
++ In earlier versions, spatial lookups accepted ``str`` and ``dict`` types
++ for new rasters, allowing file writes and network fetches.
++
+ Settings
+ ========
+
+diff --git a/tests/gis_tests/geoadmin/tests.py b/tests/gis_tests/geoadmin/tests.py
+index e101050464..2db36b49de 100644
+--- a/tests/gis_tests/geoadmin/tests.py
++++ b/tests/gis_tests/geoadmin/tests.py
+@@ -1,13 +1,26 @@
++from django.contrib.auth.models import Permission, User
++from django.contrib.contenttypes.models import ContentType
+ from django.contrib.gis.geos import Point
+-from django.test import SimpleTestCase, override_settings
++from django.core.exceptions import SuspiciousOperation
++from django.test import RequestFactory, TestCase, override_settings
+
+ from .models import City, site, site_gis, site_gis_custom
+
+
+ @override_settings(ROOT_URLCONF="django.contrib.gis.tests.geoadmin.urls")
+-class GeoAdminTest(SimpleTestCase):
++class GeoAdminTest(TestCase):
+ admin_site = site # ModelAdmin
+
++ @classmethod
++ def setUpTestData(cls):
++ cls.user = User.objects.create_user("test", password="password", is_staff=True)
++ cls.user.user_permissions.add(
++ Permission.objects.get(
++ codename="view_city",
++ content_type=ContentType.objects.get_for_model(City),
++ )
++ )
++
+ def test_widget_empty_string(self):
+ geoadmin = self.admin_site.get_model_admin(City)
+ form = geoadmin.get_changelist_form(None)({"point": ""})
+@@ -54,6 +67,14 @@ class GeoAdminTest(SimpleTestCase):
+ self.assertIs(has_changed(initial, data_almost_same), False)
+ self.assertIs(has_changed(initial, data_changed), True)
+
++ def test_raster_lookup_not_allowed(self):
++ geoadmin = self.admin_site.get_model_admin(City)
++ request = RequestFactory().get("/city/", data={"point": "/vsicurl/someurl"})
++ request.user = self.user
++ msg = "Cannot use object '/vsicurl/someurl' for a spatial lookup parameter."
++ with self.assertRaisesMessage(SuspiciousOperation, msg):
++ geoadmin.get_changelist_instance(request)
++
+
+ class GISAdminTests(GeoAdminTest):
+ admin_site = site_gis # GISModelAdmin
+diff --git a/tests/gis_tests/geoapp/tests.py b/tests/gis_tests/geoapp/tests.py
+index 7ee47ee9a8..6be13d4907 100644
+--- a/tests/gis_tests/geoapp/tests.py
++++ b/tests/gis_tests/geoapp/tests.py
+@@ -1,7 +1,10 @@
++import json
+ from io import StringIO
++from pathlib import Path
+
+ from django.contrib.gis import gdal
+ from django.contrib.gis.db.models import Extent, MakeLine, Union, functions
++from django.contrib.gis.gdal.raster.source import DisallowedRasterLookup
+ from django.contrib.gis.geos import (
+ GeometryCollection,
+ GEOSGeometry,
+@@ -21,6 +24,7 @@ from django.db.models import F, OuterRef, Subquery
+ from django.test import TestCase, skipUnlessDBFeature
+ from django.test.utils import CaptureQueriesContext
+
++from ..data.rasters.textrasters import JSON_RASTER
+ from ..utils import skipUnlessGISLookup
+ from .models import (
+ City,
+@@ -598,6 +602,93 @@ class GeoLookupTest(TestCase):
+ )
+ self.assertEqual(qs.get(), multifields)
+
++ def test_lookup_rejects_writing_or_fetching_rasters(self):
++ """
++ GDALRaster enables write mode in the following cases even when the
++ value of the `write` parameter is False (default):
++ - dicts
++ - 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.
++
++ Disallowing strings also disallows paths to local or network rasters,
++ but those didn't work in the lookup context anyway, since they were
++ never opened for writing, and lookups failed on setting the SRID with:
++
++ GDALException: Raster needs to be opened in write mode to change values
++
++ Still, a network fetch might have occurred before that failure point,
++ so disallow strings altogether.
++ """
++ # Create a vsi-based raster from scratch.
++ vsimem_path = "/vsimem/raster.tif"
++ # Keep a reference to this raster while it is being re-parsed below.
++ # Otherwise, GDALRaster.__del__() will delete the in-memory raster.
++ _rast = gdal.GDALRaster( # NOQA: F841
++ {
++ "name": vsimem_path,
++ "driver": "tif",
++ "width": 4,
++ "height": 4,
++ "srid": 4326,
++ "bands": [
++ {
++ "data": range(16),
++ }
++ ],
++ }
++ )
++ existing_path = Path(__file__).parent.parent / "data" / "rasters" / "raster.tif"
++ disallowed_cases = [
++ JSON_RASTER,
++ json.loads(JSON_RASTER),
++ "/vsicurl/someurl",
++ "/vsicurl_streaming/someurl",
++ "/vsis3/someurl",
++ vsimem_path,
++ existing_path,
++ ]
++ 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."
++ )
++ with (
++ self.subTest(obj=obj),
++ self.assertRaisesMessage(DisallowedRasterLookup, msg),
++ ):
++ City.objects.filter(point__contained=obj)
++
++ # Strings having nothing to do with rasters raise a more generic error.
++ for obj in str(existing_path), "invalid":
++ msg = "String input unrecognized as WKT EWKT, and HEXEWKB."
++ 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_allows_geos_geometry_string(self):
++ geojson = json.dumps({"type": "Point", "coordinates": [2, 49]})
++ # Just get SQL to avoid gating on connection.supports_raster.
++ City.objects.filter(point__contained=geojson).query
++
+
+ class GeoQuerySetTest(TestCase):
+ # TODO: GeoQuerySet is removed, organize these test better.
+diff --git a/tests/gis_tests/rasterapp/test_rasterfield.py b/tests/gis_tests/rasterapp/test_rasterfield.py
+index 3f2ce770a9..37eec50027 100644
+--- a/tests/gis_tests/rasterapp/test_rasterfield.py
++++ b/tests/gis_tests/rasterapp/test_rasterfield.py
+@@ -207,7 +207,7 @@ class RasterFieldTest(TransactionTestCase):
+ (stx_pnt, 0, 500),
+ (stx_pnt, D(km=1000)),
+ (rast, 500),
+- (json.loads(JSON_RASTER), 500),
++ (GDALRaster(json.loads(JSON_RASTER)), 500),
+ ]
+ elif name == "relate":
+ # Set lookup values for the relate lookup.
+@@ -218,7 +218,7 @@ class RasterFieldTest(TransactionTestCase):
+ (stx_pnt, 0, "T*T***FF*"),
+ (stx_pnt, "T*T***FF*"),
+ (rast, "T*T***FF*"),
+- (json.loads(JSON_RASTER), "T*T***FF*"),
++ (GDALRaster(json.loads(JSON_RASTER)), "T*T***FF*"),
+ ]
+ elif name == "isvalid":
+ # The isvalid lookup doesn't make sense for rasters.
+@@ -232,7 +232,7 @@ class RasterFieldTest(TransactionTestCase):
+ (stx_pnt, 0),
+ stx_pnt,
+ rast,
+- json.loads(JSON_RASTER),
++ GDALRaster(json.loads(JSON_RASTER)),
+ ]
+ else:
+ # Override band lookup for these, as it's not supported.
+@@ -245,7 +245,7 @@ class RasterFieldTest(TransactionTestCase):
+ stx_pnt,
+ stx_pnt,
+ rast,
+- json.loads(JSON_RASTER),
++ GDALRaster(json.loads(JSON_RASTER)),
+ ]
+
+ # Create query filter combinations.
+@@ -287,14 +287,6 @@ class RasterFieldTest(TransactionTestCase):
+ qs = RasterModel.objects.filter(rastprojected__dwithin=(rast, D(km=1)))
+ self.assertEqual(qs.count(), 1)
+
+- qs = RasterModel.objects.filter(
+- rastprojected__dwithin=(json.loads(JSON_RASTER), D(km=1))
+- )
+- self.assertEqual(qs.count(), 1)
+-
+- qs = RasterModel.objects.filter(rastprojected__dwithin=(JSON_RASTER, D(km=1)))
+- self.assertEqual(qs.count(), 1)
+-
+ # Filter in an unprojected coordinate system.
+ qs = RasterModel.objects.filter(rast__dwithin=(rast, 40))
+ self.assertEqual(qs.count(), 1)
+@@ -414,13 +406,8 @@ class RasterFieldTest(TransactionTestCase):
+ self.assertEqual(qs.count(), 0)
+
+ def test_lookup_value_error(self):
+- # Test with invalid dict lookup parameter
+- obj = {}
+- msg = "Couldn't create spatial object from lookup value '%s'." % obj
+- with self.assertRaisesMessage(ValueError, msg):
+- RasterModel.objects.filter(geom__intersects=obj)
+ # Test with invalid string lookup parameter
+- obj = "00000"
++ obj = "POINT()"
+ msg = "Couldn't create spatial object from lookup value '%s'." % obj
+ with self.assertRaisesMessage(ValueError, msg):
+ RasterModel.objects.filter(geom__intersects=obj)
+@@ -449,7 +436,7 @@ class RasterFieldTest(TransactionTestCase):
+ def test_lhs_with_index_rhs_without_index(self):
+ with CaptureQueriesContext(connection) as queries:
+ RasterModel.objects.filter(
+- rast__0__contains=json.loads(JSON_RASTER)
++ rast__0__contains=GDALRaster(json.loads(JSON_RASTER))
+ ).exists()
+ # It's easier to check the indexes in the generated SQL than to write
+ # tests that cover all index combinations.
+diff --git a/tests/gis_tests/test_geoforms.py b/tests/gis_tests/test_geoforms.py
+index b8105645bf..b980892790 100644
+--- a/tests/gis_tests/test_geoforms.py
++++ b/tests/gis_tests/test_geoforms.py
+@@ -8,6 +8,8 @@ from django.test import SimpleTestCase, override_settings
+ from django.utils.deprecation import RemovedInDjango51Warning
+ from django.utils.html import escape
+
++from .data.rasters.textrasters import JSON_RASTER
++
+
+ class GeometryFieldTest(SimpleTestCase):
+ def test_init(self):
+@@ -82,6 +84,19 @@ class GeometryFieldTest(SimpleTestCase):
+ with self.assertRaises(ValidationError):
+ pnt_fld.clean("LINESTRING(0 0, 1 1)")
+
++ def test_raster_types(self):
++ fld = forms.GeometryField()
++ for value in (
++ JSON_RASTER,
++ str(JSON_RASTER),
++ "/vsicurl/http://example.com/raster.tif",
++ ):
++ with (
++ self.subTest(value=value),
++ self.assertRaisesMessage(ValidationError, "Invalid geometry value."),
++ ):
++ fld.clean(value)
++
+ def test_to_python(self):
+ """
+ to_python() either returns a correct GEOSGeometry object or
+--
+2.44.4
@@ -9,6 +9,7 @@ SRC_URI += "file://CVE-2025-64460.patch \
file://CVE-2025-64459-2.patch \
file://CVE-2025-57833.patch \
file://CVE-2025-59681.patch \
+ file://CVE-2026-15307.patch \
"
SRC_URI[sha256sum] = "29019a5763dbd48da1720d687c3522ef40d1c61be6fb2fad27ed79e9f655bc11"