Visitar URL original
Fixed CVE-2026-87890 -- Blocked bytes in spatial lookups. · django/django@ebcb13b · GitHub
Skip to content

Commit ebcb13b

Browse files
committed
Fixed CVE-2026-87890 -- Blocked bytes in spatial lookups.
Even though raster values provided via bytes write to the in-memory virtual filesystem, that does not prevent network fetches when resolving references. Thank you to sicksec for the report, and Jacob Walls, Jake Howard, and David Smith for reviews.
1 parent 7ff7fcc commit ebcb13b

8 files changed

Lines changed: 124 additions & 41 deletions

File tree

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,11 +221,15 @@ def get_prep_value(self, value):
221221
try:
222222
obj = GEOSGeometry(obj, max_geom_collections=max_geom_collections)
223223
except (TypeError, ValueError) as err:
224-
if isinstance(obj, str) and obj.startswith(VSI_FILESYSTEM_PREFIX):
224+
if isinstance(obj, bytes) or (
225+
isinstance(obj, str) and obj.startswith(VSI_FILESYSTEM_PREFIX)
226+
):
225227
raise blocked_err
226228
raise err
227229
except (GEOSException, GDALException):
228-
if isinstance(obj, str) and json_regex.match(obj):
230+
if isinstance(obj, bytes) or (
231+
isinstance(obj, str) and json_regex.match(obj)
232+
):
229233
raise blocked_err
230234
raise ValueError(
231235
"Couldn't create spatial object from lookup value '%s'." % obj

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@
3434

3535
class DisallowedRasterLookup(SuspiciousOperation):
3636
"""
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.
37+
Types that force GDALRaster to open in write mode or values that could
38+
reference external sources, are not allowed in lookup contexts.
3939
Instead, wrap values in GDALRaster explicitly.
4040
"""
4141

@@ -249,12 +249,19 @@ def check_raster_lookup_value(cls, ds_input):
249249
"""
250250
Raise DisallowedRasterLookup for values inappropriate in lookups:
251251
- No dicts, which GDALRaster(write=False) might still write to.
252-
- No strings or Paths, which might fetch over the virtual filesystem.
252+
- No strings, bytes, or Paths, which might fetch over the virtual
253+
filesystem.
253254
"""
254255
normalized = cls._preprocess_input(ds_input)
255-
if isinstance(normalized, (dict, str)):
256+
if isinstance(normalized, (dict, str, bytes)):
257+
value_repr = repr(normalized)
258+
if len(value_repr) > 4096:
259+
value_repr = "%s… <trimmed %d bytes string>" % (
260+
value_repr[:4096],
261+
len(value_repr),
262+
)
256263
msg = (
257-
f"Cannot use object {normalized!r} for a spatial lookup "
264+
f"Cannot use object {value_repr} for a spatial lookup "
258265
"parameter. If this is a raster, wrap it with GDALRaster() "
259266
"before using it in a lookup to enable writing or fetching."
260267
)

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,10 @@ Filters on 'normal' fields (e.g. :class:`~django.db.models.CharField`)
149149
may be chained with those on geographic fields. Geographic lookups accept
150150
geometry and raster input on both sides, and input types can be mixed freely in
151151
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>`.
152+
types such as ``bytes``, ``str``, :class:`pathlib.Path`, and ``dict`` must be
153+
wrapped by :class:`~django.contrib.gis.gdal.GDALRaster` to signify that the
154+
potential for file writing or network fetching is acceptable. For the
155+
rationale, see :ref:`raster security considerations <raster-security>`.
156156

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

‎docs/ref/contrib/gis/gdal.txt‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2089,6 +2089,13 @@ Here's how to create a raster and return it as a file in an
20892089
... )
20902090
>>> HttpResponse(rast.vsi_buffer, "image/tiff")
20912091

2092+
.. warning::
2093+
2094+
The memory-based virtual filesystem doesn't make untrusted raster data
2095+
safe. Raster data may reference local or remote resources, for example
2096+
through a VRT source, causing GDAL to access files or make network
2097+
requests. Only instantiate :class:`GDALRaster` from trusted input.
2098+
20922099
Using other Virtual Filesystems
20932100
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
20942101

@@ -2150,17 +2157,14 @@ essential to prevent instantiating one from untrusted input. Otherwise, an
21502157
attacker might gain the ability to write a file or make a network request.
21512158

21522159
To mitigate this, :ref:`spatial lookups <spatial-lookups-intro>` prevent
2153-
``str``, :class:`pathlib.Path`, and ``dict`` values from reaching
2160+
``bytes``, ``str``, :class:`pathlib.Path`, and ``dict`` values from reaching
21542161
:class:`GDALRaster` altogether. To use these types with lookups, wrap them
21552162
explicitly with :class:`GDALRaster`, indicating that the value is trusted.
2156-
Bytes are accepted without being wrapped in :class:`GDALRaster` because they
2157-
are opened through GDAL's memory-based :ref:`virtual filesystem
2158-
<gdal-raster-vsimem>`.
2159-
2160-
This protection applies only to spatial lookups. Assigning a ``dict`` value to
2161-
a :class:`~django.contrib.gis.db.models.RasterField` will still open a new
2162-
raster, and assigning a ``str`` or ``Path`` will still fetch and open the
2163-
referenced raster.
2163+
2164+
This protection applies only to spatial lookups. Assigning a ``dict`` or
2165+
``bytes`` value to a :class:`~django.contrib.gis.db.models.RasterField` will
2166+
still open a new raster, and assigning a ``str`` or ``Path`` will still fetch
2167+
and open the referenced raster.
21642168

21652169
When validating geometry inputs, the
21662170
:class:`~django.contrib.gis.forms.GeometryField` form field will reject raster

‎docs/releases/5.2.18.txt‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,30 @@ of some malformed or unusual header values may differ, for example,
4141
This issue has severity "moderate" according to the :ref:`Django security
4242
policy <severity-levels>`.
4343

44+
CVE-2026-87890: Potential request forgery via spatial lookup byte values
45+
========================================================================
46+
47+
Spatial lookups accepted raster values provided as ``bytes`` without requiring
48+
them to be explicitly wrapped in :class:`~django.contrib.gis.gdal.GDALRaster`.
49+
Although these values were opened through GDAL's in-memory virtual filesystem,
50+
they could contain a VRT document referencing an external raster source. This
51+
could cause GDAL to issue network requests as the Django process user while
52+
preparing the lookup.
53+
54+
This issue could be exploited by applications that passed attacker-controlled
55+
bytes directly to a spatial lookup. It was overlooked in the fix for
56+
CVE-2026-15307.
57+
58+
To mitigate this issue, raster values provided as ``bytes`` must now be wrapped
59+
in :class:`~django.contrib.gis.gdal.GDALRaster` before being used in spatial
60+
lookups. Byte values representing valid hexadecimal geometries remain accepted.
61+
62+
This is a backward incompatible change. As a reminder, all untrusted user input
63+
should be validated before use.
64+
65+
This issue has severity "moderate" according to the :ref:`Django security
66+
policy <severity-levels>`.
67+
4468
Bugfixes
4569
========
4670

‎docs/releases/6.0.9.txt‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,30 @@ of some malformed or unusual header values may differ, for example,
4141
This issue has severity "moderate" according to the :ref:`Django security
4242
policy <severity-levels>`.
4343

44+
CVE-2026-87890: Potential request forgery via spatial lookup byte values
45+
========================================================================
46+
47+
Spatial lookups accepted raster values provided as ``bytes`` without requiring
48+
them to be explicitly wrapped in :class:`~django.contrib.gis.gdal.GDALRaster`.
49+
Although these values were opened through GDAL's in-memory virtual filesystem,
50+
they could contain a VRT document referencing an external raster source. This
51+
could cause GDAL to issue network requests as the Django process user while
52+
preparing the lookup.
53+
54+
This issue could be exploited by applications that passed attacker-controlled
55+
bytes directly to a spatial lookup. It was overlooked in the fix for
56+
CVE-2026-15307.
57+
58+
To mitigate this issue, raster values provided as ``bytes`` must now be wrapped
59+
in :class:`~django.contrib.gis.gdal.GDALRaster` before being used in spatial
60+
lookups. Byte values representing valid hexadecimal geometries remain accepted.
61+
62+
This is a backward incompatible change. As a reminder, all untrusted user input
63+
should be validated before use.
64+
65+
This issue has severity "moderate" according to the :ref:`Django security
66+
policy <severity-levels>`.
67+
4468
Bugfixes
4569
========
4670

‎docs/releases/6.1.2.txt‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,30 @@ of some malformed or unusual header values may differ, for example,
4242
This issue has severity "moderate" according to the :ref:`Django security
4343
policy <severity-levels>`.
4444

45+
CVE-2026-87890: Potential request forgery via spatial lookup byte values
46+
========================================================================
47+
48+
Spatial lookups accepted raster values provided as ``bytes`` without requiring
49+
them to be explicitly wrapped in :class:`~django.contrib.gis.gdal.GDALRaster`.
50+
Although these values were opened through GDAL's in-memory virtual filesystem,
51+
they could contain a VRT document referencing an external raster source. This
52+
could cause GDAL to issue network requests as the Django process user while
53+
preparing the lookup.
54+
55+
This issue could be exploited by applications that passed attacker-controlled
56+
bytes directly to a spatial lookup. It was overlooked in the fix for
57+
CVE-2026-15307.
58+
59+
To mitigate this issue, raster values provided as ``bytes`` must now be wrapped
60+
in :class:`~django.contrib.gis.gdal.GDALRaster` before being used in spatial
61+
lookups. Byte values representing valid hexadecimal geometries remain accepted.
62+
63+
This is a backward incompatible change. As a reminder, all untrusted user input
64+
should be validated before use.
65+
66+
This issue has severity "moderate" according to the :ref:`Django security
67+
policy <severity-levels>`.
68+
4569
Bugfixes
4670
========
4771

‎tests/gis_tests/geoapp/tests.py‎

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -749,10 +749,9 @@ def test_lookup_rejects_writing_or_fetching_rasters(self):
749749
- strings matching a json regex
750750
- bytes
751751
752-
Since this could be unexpected in a lookup context, disallow dicts
753-
and strings: instead, explicitly wrap with GDALRaster() to signal that
754-
a write or fetch is expected. Bytes only write to the in-memory virtual
755-
filesystem, so allow them.
752+
Since this could be unexpected in a lookup context, disallow dicts,
753+
bytes, and strings: instead, explicitly wrap with GDALRaster() to
754+
signal that a write or fetch is expected.
756755
757756
Disallowing strings also disallows paths to local or network rasters,
758757
but those didn't work in the lookup context anyway, since they were
@@ -790,23 +789,18 @@ def test_lookup_rejects_writing_or_fetching_rasters(self):
790789
"/vsis3/someurl",
791790
vsimem_path,
792791
existing_path,
792+
existing_path.read_bytes(),
793+
b"POINT (invalid)",
793794
]
794795
for obj in disallowed_cases:
795-
try:
796-
msg_obj = json.loads(obj)
797-
except Exception:
798-
if isinstance(obj, Path):
799-
msg_obj = str(obj)
800-
else:
801-
msg_obj = obj
802796
msg = (
803-
f"Cannot use object {msg_obj!r} for a spatial lookup parameter. "
804-
"If this is a raster, wrap it with GDALRaster() before using "
805-
"it in a lookup to enable writing or fetching."
797+
r"Cannot use object .* for a spatial lookup parameter\. If "
798+
r"this is a raster, wrap it with GDALRaster\(\) before using "
799+
r"it in a lookup to enable writing or fetching\."
806800
)
807801
with (
808802
self.subTest(obj=obj),
809-
self.assertRaisesMessage(DisallowedRasterLookup, msg),
803+
self.assertRaisesRegex(DisallowedRasterLookup, msg),
810804
):
811805
State.objects.filter(poly__intersects=obj)
812806

@@ -816,12 +810,14 @@ def test_lookup_rejects_writing_or_fetching_rasters(self):
816810
with self.subTest(obj=obj), self.assertRaisesMessage(ValueError, msg):
817811
State.objects.filter(poly__intersects=obj)
818812

819-
def test_lookup_allows_writing_raster_from_bytes(self):
820-
raster_path = Path(__file__).parent.parent / "data" / "rasters" / "raster.tif"
821-
with open(raster_path, "rb") as raster_file:
822-
raster_bytes = raster_file.read()
823-
# Just get SQL to avoid gating on connection.supports_raster.
824-
State.objects.filter(poly__intersects=raster_bytes).query
813+
def test_lookup_rejected_value_repr_is_truncated(self):
814+
value = b"x" * 5000
815+
with self.assertRaises(DisallowedRasterLookup) as ctx:
816+
State.objects.filter(poly__intersects=value)
817+
818+
message = str(ctx.exception)
819+
self.assertIn("… <trimmed 5003 bytes string>", message)
820+
self.assertNotIn("x" * 4097, message)
825821

826822
def test_lookup_allows_geos_geometry_string(self):
827823
geojson = json.dumps({"type": "Point", "coordinates": [2, 49]})

0 commit comments

Comments
 (0)