Files
meta-openembedded/meta-python/recipes-devtools/python/python3-django-5.0.14/CVE-2026-15307.patch
T
Darsh Kelaiya 3bc6095e25 python3-django: fix CVE-2026-15307
This patch applies the upstream fix as referenced in [2],
using the commit shown in [1].

[1] https://github.com/django/django/commit/115ffd0463a765ab1cc93de18e94b5459b8a300e
[2] https://nvd.nist.gov/vuln/detail/CVE-2026-15307

Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
2026-09-01 10:17:59 +05:30

564 lines
24 KiB
Diff

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