티스토리 수익 글 보기

티스토리 수익 글 보기

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

Commit 115ffd0

Browse files
jacobtylerwallsnessita
authored andcommitted
[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 f1949c1 from main.
1 parent ec5ced4 commit 115ffd0

9 files changed

Lines changed: 275 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)
@@ -200,22 +201,36 @@ def get_prep_value(self, value):
200201
obj, "__geo_interface__"
201202
)
202203
# Try to convert the input to raster.
203-
raster = self.get_raster_prep_value(obj, is_candidate)
204-
204+
raster = None
205+
blocked_err = None
206+
try:
207+
raster = self.get_raster_prep_value(obj, is_candidate)
208+
except DisallowedRasterLookup as err:
209+
if isinstance(obj, dict):
210+
raise err
211+
# Don't immediately raise in case this is a valid GEOSGeometry.
212+
blocked_err = err
205213
if raster:
206214
obj = raster
207215
elif is_candidate:
208216
try:
209217
obj = GEOSGeometry(obj)
218+
except (TypeError, ValueError) as err:
219+
if isinstance(obj, str) and obj.startswith(VSI_FILESYSTEM_PREFIX):
220+
raise blocked_err
221+
raise err
210222
except (GEOSException, GDALException):
223+
if isinstance(obj, str) and json_regex.match(obj):
224+
raise blocked_err
211225
raise ValueError(
212226
"Couldn't create spatial object from lookup value '%s'." % obj
213227
)
214228
else:
215-
raise ValueError(
229+
msg = (
216230
"Cannot use object with type %s for a spatial lookup parameter."
217231
% type(obj).__name__
218232
)
233+
raise blocked_err or ValueError(msg)
219234

220235
# Assigning the SRID value.
221236
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
):
@@ -225,6 +230,35 @@ def __repr__(self):
225230
"""
226231
return "<Raster object at %s>" % hex(addressof(self._ptr))
227232

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

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

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

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

docs/ref/contrib/gis/gdal.txt

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

21572157
.. _`GDAL Virtual Filesystems documentation`: https://gdal.org/user/virtual_file_systems.html
21582158

2159+
.. _raster-security:
2160+
2161+
Security considerations
2162+
~~~~~~~~~~~~~~~~~~~~~~~
2163+
2164+
Since :class:`GDALRaster` always opens new rasters in write mode, it is
2165+
essential to prevent instantiating one from untrusted input. Otherwise, an
2166+
attacker might gain the ability to write a file or make a network request.
2167+
2168+
To mitigate this, :ref:`spatial lookups <spatial-lookups-intro>` prevent
2169+
``str``, :class:`pathlib.Path`, and ``dict`` values from reaching
2170+
:class:`GDALRaster` altogether. To use these types with lookups, wrap them
2171+
explicitly with :class:`GDALRaster`, indicating that the value is trusted.
2172+
Bytes are accepted without being wrapped in :class:`GDALRaster` because they
2173+
are opened through GDAL's memory-based :ref:`virtual filesystem
2174+
<gdal-raster-vsimem>`.
2175+
2176+
This protection applies only to spatial lookups. Assigning a ``dict`` value to
2177+
a :class:`~django.contrib.gis.db.models.RasterField` will still open a new
2178+
raster, and assigning a ``str`` or ``Path`` will still fetch and open the
2179+
referenced raster.
2180+
2181+
When validating geometry inputs, the
2182+
:class:`~django.contrib.gis.forms.GeometryField` form field will reject raster
2183+
values. When validating raster inputs, you should write custom validation.
2184+
2185+
For defense-in-depth strategies for limiting the available raster drivers, see
2186+
`GDAL security considerations <https://gdal.org/user/security.html>`_.
2187+
2188+
.. versionchanged:: 5.2.17
2189+
2190+
In earlier versions, spatial lookups accepted ``str`` and ``dict`` types
2191+
for new rasters, allowing file writes and network fetches.
2192+
21592193
Settings
21602194
========
21612195

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>`.

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)