티스토리 수익 글 보기

티스토리 수익 글 보기

[6.1.x] Fixed CVE-2026-15307 — Blocked raster strings and dicts in s… · django/django@39b3e2d · GitHub
Skip to content

Commit 39b3e2d

Browse files
jacobtylerwallsnessita
authored andcommitted
[6.1.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 f1949c1 from main.
1 parent 370a6e2 commit 39b3e2d

10 files changed

Lines changed: 304 additions & 39 deletions

File tree

django/contrib/gis/db/models/fields.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
from django.contrib.gis import forms, gdal
44
from django.contrib.gis.db.models.proxy import SpatialProxy
55
from django.contrib.gis.gdal.error import GDALException
6+
from django.contrib.gis.gdal.raster.const import VSI_FILESYSTEM_PREFIX
7+
from django.contrib.gis.gdal.raster.source import DisallowedRasterLookup
8+
from django.contrib.gis.geometry import json_regex
69
from django.contrib.gis.geos import (
710
GeometryCollection,
811
GEOSException,
@@ -170,21 +173,19 @@ def get_db_prep_value(self, value, connection, *args, **kwargs):
170173
def get_raster_prep_value(self, value, is_candidate):
171174
"""
172175
Return a GDALRaster if conversion is successful, otherwise return None.
176+
177+
Unless the user opts in by wrapping values in a GDALRaster, raise
178+
DisallowedRasterLookup for values that fetch or write to disk.
173179
"""
174180
if isinstance(value, gdal.GDALRaster):
175181
return value
176-
elif is_candidate:
182+
gdal.GDALRaster.check_raster_lookup_value(value)
183+
if is_candidate:
177184
try:
178185
return gdal.GDALRaster(value)
179186
except GDALException:
180187
pass
181-
elif isinstance(value, dict):
182-
try:
183-
return gdal.GDALRaster(value)
184-
except GDALException:
185-
raise ValueError(
186-
"Couldn't create spatial object from lookup value '%s'." % value
187-
)
188+
return None
188189

189190
def get_prep_value(self, value):
190191
obj = super().get_prep_value(value)
@@ -201,22 +202,36 @@ def get_prep_value(self, value):
201202
obj, "__geo_interface__"
202203
)
203204
# Try to convert the input to raster.
204-
raster = self.get_raster_prep_value(obj, is_candidate)
205-
205+
raster = None
206+
blocked_err = None
207+
try:
208+
raster = self.get_raster_prep_value(obj, is_candidate)
209+
except DisallowedRasterLookup as err:
210+
if isinstance(obj, dict):
211+
raise err
212+
# Don't immediately raise in case this is a valid GEOSGeometry.
213+
blocked_err = err
206214
if raster:
207215
obj = raster
208216
elif is_candidate:
209217
try:
210218
obj = GEOSGeometry(obj)
219+
except (TypeError, ValueError) as err:
220+
if isinstance(obj, str) and obj.startswith(VSI_FILESYSTEM_PREFIX):
221+
raise blocked_err
222+
raise err
211223
except (GEOSException, GDALException):
224+
if isinstance(obj, str) and json_regex.match(obj):
225+
raise blocked_err
212226
raise ValueError(
213227
"Couldn't create spatial object from lookup value '%s'." % obj
214228
)
215229
else:
216-
raise ValueError(
230+
msg = (
217231
"Cannot use object with type %s for a spatial lookup parameter."
218232
% type(obj).__name__
219233
)
234+
raise blocked_err or ValueError(msg)
220235

221236
# Assigning the SRID value.
222237
obj.srid = self.get_srid(obj)

django/contrib/gis/gdal/raster/source.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,19 @@
2727
)
2828
from django.contrib.gis.gdal.srs import SpatialReference, SRSException
2929
from django.contrib.gis.geometry import json_regex
30+
from django.core.exceptions import SuspiciousOperation
3031
from django.utils.encoding import force_bytes, force_str
3132
from django.utils.functional import cached_property
3233

3334

35+
class DisallowedRasterLookup(SuspiciousOperation):
36+
"""
37+
Types that force GDALRaster to open in write mode (dict) or values that
38+
could be virtual filesystem paths (str) are not allowed in lookup contexts.
39+
Instead, wrap values in GDALRaster explicitly.
40+
"""
41+
42+
3443
class TransformPoint(list):
3544
indices = {
3645
"origin": (0, 3),
@@ -77,14 +86,10 @@ def __init__(self, ds_input, write=False):
7786
self._write = 1 if write else 0
7887
Driver.ensure_registered()
7988

80-
# Preprocess json inputs. This converts json strings to dictionaries,
81-
# which are parsed below the same way as direct dictionary inputs.
82-
if isinstance(ds_input, str) and json_regex.match(ds_input):
83-
ds_input = json.loads(ds_input)
89+
ds_input = self._preprocess_input(ds_input)
8490

8591
# If input is a valid file path, try setting file as source.
86-
if isinstance(ds_input, (str, Path)):
87-
ds_input = str(ds_input)
92+
if isinstance(ds_input, str):
8893
if not ds_input.startswith(VSI_FILESYSTEM_PREFIX) and not os.path.exists(
8994
ds_input
9095
):
@@ -226,6 +231,35 @@ def __repr__(self):
226231
"""
227232
return "<Raster object at %s>" % hex(addressof(self._ptr))
228233

234+
@classmethod
235+
def _preprocess_input(cls, ds_input):
236+
"""
237+
Preprocess json and Path inputs. This converts json strings to
238+
dictionaries, which are then parsed just like direct dictionary inputs.
239+
This also stringifies Path objects.
240+
"""
241+
if isinstance(ds_input, str) and json_regex.match(ds_input):
242+
ds_input = json.loads(ds_input)
243+
if isinstance(ds_input, Path):
244+
ds_input = str(ds_input)
245+
return ds_input
246+
247+
@classmethod
248+
def check_raster_lookup_value(cls, ds_input):
249+
"""
250+
Raise DisallowedRasterLookup for values inappropriate in lookups:
251+
- No dicts, which GDALRaster(write=False) might still write to.
252+
- No strings or Paths, which might fetch over the virtual filesystem.
253+
"""
254+
normalized = cls._preprocess_input(ds_input)
255+
if isinstance(normalized, (dict, str)):
256+
msg = (
257+
f"Cannot use object {normalized!r} for a spatial lookup "
258+
"parameter. If this is a raster, wrap it with GDALRaster() "
259+
"before using it in a lookup to enable writing or fetching."
260+
)
261+
raise DisallowedRasterLookup(msg)
262+
229263
def _flush(self):
230264
"""
231265
Flush all data from memory into the source file if it exists.

docs/ref/contrib/gis/db-api.txt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,21 @@ GeoDjango are only available on spatial fields.
147147

148148
Filters on 'normal' fields (e.g. :class:`~django.db.models.CharField`)
149149
may be chained with those on geographic fields. Geographic lookups accept
150-
geometry and raster input on both sides and input types can be mixed freely.
150+
geometry and raster input on both sides, and input types can be mixed freely in
151+
most cases. However, unlike assignments to model fields, with lookups,
152+
types such as ``str``, :class:`pathlib.Path`, and ``dict`` must be wrapped by
153+
:class:`~django.contrib.gis.gdal.GDALRaster` to signify that the potential for
154+
file writing or network fetching is acceptable. For the rationale, see
155+
:ref:`raster security considerations <raster-security>`.
151156

152157
The general structure of geographic lookups is described below. A complete
153158
reference can be found in the :ref:`spatial lookup reference<spatial-lookups>`.
154159

160+
.. versionchanged:: 5.2.17
161+
162+
In earlier versions, spatial lookups accepted ``str`` and ``dict`` types
163+
for new rasters, allowing file writes and network fetches.
164+
155165
Geometry Lookups
156166
----------------
157167

docs/ref/contrib/gis/gdal.txt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2132,6 +2132,40 @@ previously configured for authentication and possibly other settings (see the
21322132

21332133
.. _`GDAL Virtual Filesystems documentation`: https://gdal.org/user/virtual_file_systems.html
21342134

2135+
.. _raster-security:
2136+
2137+
Security considerations
2138+
~~~~~~~~~~~~~~~~~~~~~~~
2139+
2140+
Since :class:`GDALRaster` always opens new rasters in write mode, it is
2141+
essential to prevent instantiating one from untrusted input. Otherwise, an
2142+
attacker might gain the ability to write a file or make a network request.
2143+
2144+
To mitigate this, :ref:`spatial lookups <spatial-lookups-intro>` prevent
2145+
``str``, :class:`pathlib.Path`, and ``dict`` values from reaching
2146+
:class:`GDALRaster` altogether. To use these types with lookups, wrap them
2147+
explicitly with :class:`GDALRaster`, indicating that the value is trusted.
2148+
Bytes are accepted without being wrapped in :class:`GDALRaster` because they
2149+
are opened through GDAL's memory-based :ref:`virtual filesystem
2150+
<gdal-raster-vsimem>`.
2151+
2152+
This protection applies only to spatial lookups. Assigning a ``dict`` value to
2153+
a :class:`~django.contrib.gis.db.models.RasterField` will still open a new
2154+
raster, and assigning a ``str`` or ``Path`` will still fetch and open the
2155+
referenced raster.
2156+
2157+
When validating geometry inputs, the
2158+
:class:`~django.contrib.gis.forms.GeometryField` form field will reject raster
2159+
values. When validating raster inputs, you should write custom validation.
2160+
2161+
For defense-in-depth strategies for limiting the available raster drivers, see
2162+
`GDAL security considerations <https://gdal.org/user/security.html>`_.
2163+
2164+
.. versionchanged:: 5.2.17
2165+
2166+
In earlier versions, spatial lookups accepted ``str`` and ``dict`` types
2167+
for new rasters, allowing file writes and network fetches.
2168+
21352169
Settings
21362170
========
21372171

docs/releases/5.2.17.txt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,32 @@ Django 5.2.17 release notes
77
Django 5.2.17 fixes one security issue with severity "high", two security
88
issues with severity "moderate", and one security issue with severity "low" in
99
5.2.16.
10+
11+
CVE-2026-15307: Server-side file-write and request forgery via spatial lookups
12+
==============================================================================
13+
14+
Spatial lookups allowed ``str`` and ``dict`` lookup values to be passed to
15+
:class:`~django.contrib.gis.gdal.GDALRaster` when they represented rasters.
16+
Depending on the raster driver, this could write a file to disk (in some cases
17+
enabling remote code execution) or issue a network request as the Django
18+
process user. Because the admin changelist permits filtering via
19+
:meth:`~django.contrib.admin.ModelAdmin.lookup_allowed`, the flaw was reachable
20+
by staff users with view permission on any registered model containing a
21+
spatial field.
22+
23+
The following types are now disallowed by spatial lookups:
24+
25+
- ``dict``
26+
- A ``str`` that is not a valid
27+
:class:`~django.contrib.gis.geos.GEOSGeometry`, e.g. a serialized dictionary
28+
29+
This is a backward incompatible change. As a reminder, all untrusted user input
30+
should be validated before use. For that reason, assignments to model fields
31+
are unaffected and still accept these input types.
32+
33+
For guidance on how to keep using these types in spatial lookups, on validating
34+
untrusted input, and on further security considerations, see
35+
:ref:`raster security considerations <raster-security>`.
36+
37+
This issue has severity "high" according to the :ref:`Django security policy
38+
<severity-levels>`.

docs/releases/6.0.8.txt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,35 @@ Django 6.0.8 fixes one security issue with severity "high", two security issues
88
with severity "moderate", one security issue with severity "low", and several
99
bugs in 6.0.7.
1010

11+
CVE-2026-15307: Server-side file-write and request forgery via spatial lookups
12+
==============================================================================
13+
14+
Spatial lookups allowed ``str`` and ``dict`` lookup values to be passed to
15+
:class:`~django.contrib.gis.gdal.GDALRaster` when they represented rasters.
16+
Depending on the raster driver, this could write a file to disk (in some cases
17+
enabling remote code execution) or issue a network request as the Django
18+
process user. Because the admin changelist permits filtering via
19+
:meth:`~django.contrib.admin.ModelAdmin.lookup_allowed`, the flaw was reachable
20+
by staff users with view permission on any registered model containing a
21+
spatial field.
22+
23+
The following types are now disallowed by spatial lookups:
24+
25+
- ``dict``
26+
- A ``str`` that is not a valid
27+
:class:`~django.contrib.gis.geos.GEOSGeometry`, e.g. a serialized dictionary
28+
29+
This is a backward incompatible change. As a reminder, all untrusted user input
30+
should be validated before use. For that reason, assignments to model fields
31+
are unaffected and still accept these input types.
32+
33+
For guidance on how to keep using these types in spatial lookups, on validating
34+
untrusted input, and on further security considerations, see
35+
:ref:`raster security considerations <raster-security>`.
36+
37+
This issue has severity "high" according to the :ref:`Django security policy
38+
<severity-levels>`.
39+
1140
Bugfixes
1241
========
1342

tests/gis_tests/geoadmin/tests.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
1+
from django.contrib.auth.models import Permission, User
2+
from django.contrib.contenttypes.models import ContentType
13
from django.contrib.gis.geos import Point
2-
from django.test import SimpleTestCase, override_settings
4+
from django.core.exceptions import SuspiciousOperation
5+
from django.test import RequestFactory, TestCase, override_settings
36

47
from .models import City, site, site_gis, site_gis_custom
58

69

710
@override_settings(ROOT_URLCONF="django.contrib.gis.tests.geoadmin.urls")
8-
class GeoAdminTest(SimpleTestCase):
11+
class GeoAdminTest(TestCase):
912
admin_site = site # ModelAdmin
1013

14+
@classmethod
15+
def setUpTestData(cls):
16+
cls.user = User.objects.create_user("test", password="password", is_staff=True)
17+
cls.user.user_permissions.add(
18+
Permission.objects.get(
19+
codename="view_city",
20+
content_type=ContentType.objects.get_for_model(City),
21+
)
22+
)
23+
1124
def test_widget_empty_string(self):
1225
geoadmin = self.admin_site.get_model_admin(City)
1326
form = geoadmin.get_changelist_form(None)({"point": ""})
@@ -54,6 +67,14 @@ def test_widget_has_changed(self):
5467
self.assertIs(has_changed(initial, data_almost_same), False)
5568
self.assertIs(has_changed(initial, data_changed), True)
5669

70+
def test_raster_lookup_not_allowed(self):
71+
geoadmin = self.admin_site.get_model_admin(City)
72+
request = RequestFactory().get("/city/", data={"point": "/vsicurl/someurl"})
73+
request.user = self.user
74+
msg = "Cannot use object '/vsicurl/someurl' for a spatial lookup parameter."
75+
with self.assertRaisesMessage(SuspiciousOperation, msg):
76+
geoadmin.get_changelist_instance(request)
77+
5778

5879
class GISAdminTests(GeoAdminTest):
5980
admin_site = site_gis # GISModelAdmin

0 commit comments

Comments
 (0)