From f1949c1f9758947ade984c895ff16bef46f56520 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Thu, 9 Jul 2026 11:07:28 -0400 Subject: [PATCH 1/6] 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. --- 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 +++++++ docs/releases/5.2.17.txt | 29 ++++++ docs/releases/6.0.8.txt | 29 ++++++ 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 +++ 10 files changed, 304 insertions(+), 39 deletions(-) diff --git a/django/contrib/gis/db/models/fields.py b/django/contrib/gis/db/models/fields.py index 14076d305b72..305a50ee9875 100644 --- a/django/contrib/gis/db/models/fields.py +++ b/django/contrib/gis/db/models/fields.py @@ -3,6 +3,9 @@ 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, @@ -170,21 +173,19 @@ def get_db_prep_value(self, value, connection, *args, **kwargs): 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) @@ -201,22 +202,36 @@ def get_prep_value(self, value): 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 4f0eb875d35e..42ab1e3a7091 100644 --- a/django/contrib/gis/gdal/raster/source.py +++ b/django/contrib/gis/gdal/raster/source.py @@ -27,10 +27,19 @@ ) 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), @@ -77,14 +86,10 @@ def __init__(self, ds_input, write=False): 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 @@ def __repr__(self): """ return "" % 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 43d237b64c4c..7b2ada03849b 100644 --- a/docs/ref/contrib/gis/db-api.txt +++ b/docs/ref/contrib/gis/db-api.txt @@ -147,11 +147,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 `. The general structure of geographic lookups is described below. A complete reference can be found in the :ref:`spatial lookup reference`. +.. 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 83cd8ae586d1..33300924a7ea 100644 --- a/docs/ref/contrib/gis/gdal.txt +++ b/docs/ref/contrib/gis/gdal.txt @@ -2138,6 +2138,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 ` 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 +`. + +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 `_. + +.. 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/docs/releases/5.2.17.txt b/docs/releases/5.2.17.txt index 769513899ded..b9eee0c0e1a9 100644 --- a/docs/releases/5.2.17.txt +++ b/docs/releases/5.2.17.txt @@ -7,3 +7,32 @@ Django 5.2.17 release notes Django 5.2.17 fixes one security issue with severity "high", two security issues with severity "moderate", and one security issue with severity "low" in 5.2.16. + +CVE-2026-15307: Server-side file-write and request forgery via spatial lookups +============================================================================== + +Spatial lookups allowed ``str`` and ``dict`` lookup values to be passed to +:class:`~django.contrib.gis.gdal.GDALRaster` when they represented rasters. +Depending on the raster driver, this could write a file to disk (in some cases +enabling remote code execution) or issue a network request as the Django +process user. Because the admin changelist permits filtering via +:meth:`~django.contrib.admin.ModelAdmin.lookup_allowed`, the flaw was reachable +by staff users with view permission on any registered model containing a +spatial field. + +The following types are now disallowed by spatial lookups: + +- ``dict`` +- A ``str`` that is not a valid + :class:`~django.contrib.gis.geos.GEOSGeometry`, e.g. a serialized dictionary + +This is a backward incompatible change. As a reminder, all untrusted user input +should be validated before use. For that reason, assignments to model fields +are unaffected and still accept these input types. + +For guidance on how to keep using these types in spatial lookups, on validating +untrusted input, and on further security considerations, see +:ref:`raster security considerations `. + +This issue has severity "high" according to the :ref:`Django security policy +`. diff --git a/docs/releases/6.0.8.txt b/docs/releases/6.0.8.txt index a3c56ecb83b8..89f1b3f24286 100644 --- a/docs/releases/6.0.8.txt +++ b/docs/releases/6.0.8.txt @@ -8,6 +8,35 @@ Django 6.0.8 fixes one security issue with severity "high", two security issues with severity "moderate", one security issue with severity "low", and several bugs in 6.0.7. +CVE-2026-15307: Server-side file-write and request forgery via spatial lookups +============================================================================== + +Spatial lookups allowed ``str`` and ``dict`` lookup values to be passed to +:class:`~django.contrib.gis.gdal.GDALRaster` when they represented rasters. +Depending on the raster driver, this could write a file to disk (in some cases +enabling remote code execution) or issue a network request as the Django +process user. Because the admin changelist permits filtering via +:meth:`~django.contrib.admin.ModelAdmin.lookup_allowed`, the flaw was reachable +by staff users with view permission on any registered model containing a +spatial field. + +The following types are now disallowed by spatial lookups: + +- ``dict`` +- A ``str`` that is not a valid + :class:`~django.contrib.gis.geos.GEOSGeometry`, e.g. a serialized dictionary + +This is a backward incompatible change. As a reminder, all untrusted user input +should be validated before use. For that reason, assignments to model fields +are unaffected and still accept these input types. + +For guidance on how to keep using these types in spatial lookups, on validating +untrusted input, and on further security considerations, see +:ref:`raster security considerations `. + +This issue has severity "high" according to the :ref:`Django security policy +`. + Bugfixes ======== diff --git a/tests/gis_tests/geoadmin/tests.py b/tests/gis_tests/geoadmin/tests.py index e101050464ee..2db36b49de9e 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 @@ def test_widget_has_changed(self): 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 7937a8bdc3ed..0738db663ec4 100644 --- a/tests/gis_tests/geoapp/tests.py +++ b/tests/gis_tests/geoapp/tests.py @@ -1,8 +1,11 @@ +import json from io import StringIO +from pathlib import Path from unittest import skipIf 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, @@ -22,6 +25,7 @@ from django.test import TestCase, skipUnlessDBFeature from django.test.utils import CaptureQueriesContext +from ..data.rasters.textrasters import JSON_RASTER from ..utils import cannot_save_multipoint, skipUnlessGISLookup from .models import ( City, @@ -694,6 +698,93 @@ def test_subquery_annotation(self): ) 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 89c4ec4856a7..42112d17724a 100644 --- a/tests/gis_tests/rasterapp/test_rasterfield.py +++ b/tests/gis_tests/rasterapp/test_rasterfield.py @@ -211,7 +211,7 @@ def test_all_gis_lookups_with_rasters(self): (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. @@ -222,7 +222,7 @@ def test_all_gis_lookups_with_rasters(self): (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. @@ -236,7 +236,7 @@ def test_all_gis_lookups_with_rasters(self): (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. @@ -249,7 +249,7 @@ def test_all_gis_lookups_with_rasters(self): stx_pnt, stx_pnt, rast, - json.loads(JSON_RASTER), + GDALRaster(json.loads(JSON_RASTER)), ] # Create query filter combinations. @@ -291,14 +291,6 @@ def test_dwithin_gis_lookup_output_with_rasters(self): 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) @@ -459,13 +451,8 @@ def test_lookup_with_polygonized_raster(self): 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) @@ -494,7 +481,7 @@ def test_db_function_errors(self): 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 4119902ead86..33fdc8f679b1 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.html import escape +from .data.rasters.textrasters import JSON_RASTER + class GeometryFieldTest(SimpleTestCase): def test_init(self): @@ -84,6 +86,19 @@ def test_geom_type(self): 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 From 27137e655e442e81095f1f8f77ff3870d9fdf169 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:30:21 -0300 Subject: [PATCH 2/6] Fixed CVE-2026-15337 -- Mitigated potential DoS in check_for_language(). Language codes longer than 500 characters are now rejected before the cached lookup, so they are no longer retained as cache keys consuming memory from each process. Thanks Jaeyoung Jang for the report, and Sarah Boyce for reviews. --- django/test/signals.py | 2 +- django/utils/translation/trans_real.py | 27 ++++++++++++++++++-------- docs/ref/utils.txt | 3 +++ docs/releases/5.2.17.txt | 19 ++++++++++++++++++ docs/releases/6.0.8.txt | 19 ++++++++++++++++++ tests/i18n/tests.py | 24 ++++++++++++++++++++++- 6 files changed, 84 insertions(+), 10 deletions(-) diff --git a/django/test/signals.py b/django/test/signals.py index f594ae434b2e..d24feeaffae3 100644 --- a/django/test/signals.py +++ b/django/test/signals.py @@ -153,7 +153,7 @@ def language_changed(*, setting, **kwargs): from django.utils.translation import trans_real trans_real._translations = {} - trans_real.check_for_language.cache_clear() + trans_real.translation_catalog_exists.cache_clear() @receiver(setting_changed) diff --git a/django/utils/translation/trans_real.py b/django/utils/translation/trans_real.py index 6fb6517a1017..1a5a08991171 100644 --- a/django/utils/translation/trans_real.py +++ b/django/utils/translation/trans_real.py @@ -31,9 +31,10 @@ # magic gettext number to separate context from message CONTEXT_SEPARATOR = "\x04" -# Maximum number of characters that will be parsed from the Accept-Language -# header or cookie to prevent possible denial of service or memory exhaustion -# attacks. About 10x longer than the longest value shown on MDN’s +# Maximum length of a language code that will be processed, to prevent possible +# denial of service or memory exhaustion attacks. Language codes are taken from +# the Accept-Language header, the language cookie, the URL path prefix, or the +# set_language() view. 500 is about 10x the longest value shown on MDN's # Accept-Language page. LANGUAGE_CODE_MAX_LENGTH = 500 @@ -65,7 +66,7 @@ def reset_cache(*, setting, **kwargs): languages should no longer be accepted. """ if setting in ("LANGUAGES", "LANGUAGE_CODE"): - check_for_language.cache_clear() + translation_catalog_exists.cache_clear() get_languages.cache_clear() get_supported_language_variant.cache_clear() @@ -462,19 +463,29 @@ def all_locale_paths(): return [globalpath, *settings.LOCALE_PATHS, *app_paths] -@functools.lru_cache(maxsize=1000) def check_for_language(lang_code): """ Check whether there is a global language file for the given language code. This is used to decide whether a user-provided language is available. - lru_cache should have a maxsize to prevent from memory exhaustion attacks, - as the provided language codes are taken from the HTTP request. See also + Reject over-length codes before the cached lookup so that oversized, + attacker-controlled values are not retained as cache keys. + """ + if lang_code is None or len(lang_code) > LANGUAGE_CODE_MAX_LENGTH: + return False + return translation_catalog_exists(lang_code) + + +@functools.lru_cache(maxsize=1000) +def translation_catalog_exists(lang_code): + """Return whether a translation catalog exists for the given language code. + + lru_cache should have a maxsize to prevent memory exhaustion attacks. See: . """ # First, a quick check to make sure lang_code is well-formed (#21458) - if lang_code is None or not language_code_re.search(lang_code): + if not language_code_re.search(lang_code): return False return any( gettext_module.find("django", path, [to_locale(lang_code)]) is not None diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index 1ec0e17862f8..08a264c1e041 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -1140,6 +1140,9 @@ For a complete discussion on the usage of the following see the code (e.g. 'fr', 'pt_BR'). This is used to decide whether a user-provided language is available. + ``lang_code`` has a maximum accepted length of 500 characters. ``False`` + is returned if it exceeds this limit, before any language-file lookup. + .. function:: get_language() Returns the currently selected language code. Returns ``None`` if diff --git a/docs/releases/5.2.17.txt b/docs/releases/5.2.17.txt index b9eee0c0e1a9..843feefaa12d 100644 --- a/docs/releases/5.2.17.txt +++ b/docs/releases/5.2.17.txt @@ -36,3 +36,22 @@ untrusted input, and on further security considerations, see This issue has severity "high" according to the :ref:`Django security policy `. + +CVE-2026-15337: Potential denial-of-service vulnerability in ``check_for_language()`` +===================================================================================== + +:func:`~django.utils.translation.check_for_language` was subject to a potential +denial-of-service attack when checking many distinct, very long language codes. +Each code was used as a key in an in-memory cache, consuming process memory. + +The ``language`` value reaches this function through the +:func:`django.views.i18n.set_language` view (not active by default) from POST +data. Since request data is limited by :setting:`DATA_UPLOAD_MAX_MEMORY_SIZE` +and the cache is configured to store a maximum number of entries, the memory +that could be consumed was bounded. + +To mitigate this vulnerability, language codes longer than 500 characters are +now rejected before the cached lookup. + +This issue has severity "low" according to the :ref:`Django security policy +`. diff --git a/docs/releases/6.0.8.txt b/docs/releases/6.0.8.txt index 89f1b3f24286..c31550d89edf 100644 --- a/docs/releases/6.0.8.txt +++ b/docs/releases/6.0.8.txt @@ -37,6 +37,25 @@ untrusted input, and on further security considerations, see This issue has severity "high" according to the :ref:`Django security policy `. +CVE-2026-15337: Potential denial-of-service vulnerability in ``check_for_language()`` +===================================================================================== + +:func:`~django.utils.translation.check_for_language` was subject to a potential +denial-of-service attack when checking many distinct, very long language codes. +Each code was used as a key in an in-memory cache, consuming process memory. + +The ``language`` value reaches this function through the +:func:`django.views.i18n.set_language` view (not active by default) from POST +data. Since request data is limited by :setting:`DATA_UPLOAD_MAX_MEMORY_SIZE` +and the cache is configured to store a maximum number of entries, the memory +that could be consumed was bounded. + +To mitigate this vulnerability, language codes longer than 500 characters are +now rejected before the cached lookup. + +This issue has severity "low" according to the :ref:`Django security policy +`. + Bugfixes ======== diff --git a/tests/i18n/tests.py b/tests/i18n/tests.py index 91e7c95f67a0..d3db80efc6f5 100644 --- a/tests/i18n/tests.py +++ b/tests/i18n/tests.py @@ -59,7 +59,10 @@ translation_file_changed, watch_for_translation_changes, ) -from django.utils.translation.trans_real import LANGUAGE_CODE_MAX_LENGTH +from django.utils.translation.trans_real import ( + LANGUAGE_CODE_MAX_LENGTH, + translation_catalog_exists, +) from .forms import CompanyForm, I18nForm, SelectDateForm from .models import Company, TestModel @@ -2081,6 +2084,25 @@ def test_check_for_language(self): self.assertFalse(check_for_language("tr-TR.UTF8")) self.assertFalse(check_for_language("de-DE.utf-8")) + def test_check_for_language_lang_code_max_length(self): + self.addCleanup(translation_catalog_exists.cache_clear) + + # Overly long codes are rejected before the cached lookup, so they are + # not retained as cache keys, potentially consuming too much memory. + # Codes at the maximum length can reach the cached lookup. + for length, cache_size in [ + (LANGUAGE_CODE_MAX_LENGTH - 1, 1), + (LANGUAGE_CODE_MAX_LENGTH, 1), + (LANGUAGE_CODE_MAX_LENGTH + 1, 0), + ]: + translation_catalog_exists.cache_clear() + with self.subTest(length=length): + self.assertIs(check_for_language("a" * length), False) + self.assertEqual( + translation_catalog_exists.cache_info().currsize, + cache_size, + ) + def test_check_for_language_null(self): self.assertIs(trans_null.check_for_language("en"), True) From d2e59b77fe18de318a8272c2a7bbc798d84d1d0d Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Wed, 15 Jul 2026 15:39:27 -0400 Subject: [PATCH 3/6] Fixed CVE-2026-15830 -- Mitigated potential DoS via nested geometry collections. Since deeply nested geometry collections can lead to fatal errors in GEOS, a new `max_geom_collections` argument on geometry model and form fields, passed down to `GEOSGeometry` itself, allows limiting either depth (WKT) or total number (WKB) before reaching GEOS. Thanks Andrew MacPherson and kimchunbok_ for the reports, and Natalia Bidart, Simon Charette, and Sarah Boyce for reviews. --- .../contrib/gis/db/backends/mysql/features.py | 14 ++ .../gis/db/backends/mysql/operations.py | 4 +- .../gis/db/backends/oracle/features.py | 4 + .../gis/db/backends/oracle/operations.py | 5 +- .../gis/db/backends/postgis/operations.py | 9 +- .../gis/db/backends/spatialite/operations.py | 5 +- django/contrib/gis/db/models/fields.py | 18 +- django/contrib/gis/db/models/proxy.py | 7 +- django/contrib/gis/forms/fields.py | 16 +- django/contrib/gis/forms/widgets.py | 4 +- django/contrib/gis/geos/geometry.py | 31 ++- django/contrib/gis/geos/prototypes/io.py | 155 ++++++++++++++- docs/ref/contrib/gis/forms-api.txt | 24 +++ docs/ref/contrib/gis/geos.txt | 8 +- docs/ref/contrib/gis/model-api.txt | 14 ++ docs/releases/5.2.17.txt | 24 +++ docs/releases/6.0.8.txt | 24 +++ tests/gis_tests/geoapp/models.py | 2 +- tests/gis_tests/geoapp/tests.py | 56 +++++- tests/gis_tests/geos_tests/test_geos_limit.py | 176 ++++++++++++++++++ tests/gis_tests/rasterapp/test_rasterfield.py | 16 ++ tests/gis_tests/test_fields.py | 33 ++++ tests/gis_tests/test_geoforms.py | 50 +++++ 23 files changed, 666 insertions(+), 33 deletions(-) create mode 100644 tests/gis_tests/geos_tests/test_geos_limit.py diff --git a/django/contrib/gis/db/backends/mysql/features.py b/django/contrib/gis/db/backends/mysql/features.py index 4e46ba40f3d0..d359bcc4e66e 100644 --- a/django/contrib/gis/db/backends/mysql/features.py +++ b/django/contrib/gis/db/backends/mysql/features.py @@ -20,3 +20,17 @@ def supports_geometry_field_unique_index(self): # Not supported in MySQL since # https://dev.mysql.com/worklog/task/?id=11808 return self.connection.mysql_is_mariadb + + @cached_property + def django_test_skips(self): + skips = super().django_test_skips + if self.connection.mysql_is_mariadb: + skips.update( + { + "MariaDB doesn't support nested geometry collections.": { + "gis_tests.geoapp.tests.SaveLoadTests." + "test_geometrycollectionfield_default_max_ignored_on_read", + }, + } + ) + return skips diff --git a/django/contrib/gis/db/backends/mysql/operations.py b/django/contrib/gis/db/backends/mysql/operations.py index f48e05b67d7a..e5db38c79d2e 100644 --- a/django/contrib/gis/db/backends/mysql/operations.py +++ b/django/contrib/gis/db/backends/mysql/operations.py @@ -141,7 +141,9 @@ def get_geometry_converter(self, expression): def converter(value, expression, connection): if value is not None: - geom = GEOSGeometryBase(read(memoryview(value)), geom_class) + geom = GEOSGeometryBase( + read(memoryview(value), max_geom_collections=None), geom_class + ) if srid: geom.srid = srid return geom diff --git a/django/contrib/gis/db/backends/oracle/features.py b/django/contrib/gis/db/backends/oracle/features.py index f346d935738c..16279b8a1ea8 100644 --- a/django/contrib/gis/db/backends/oracle/features.py +++ b/django/contrib/gis/db/backends/oracle/features.py @@ -23,6 +23,10 @@ def django_test_skips(self): "gis_tests.gis_migrations.test_operations.OperationTests." "test_add_check_constraint", }, + "Oracle doesn't support nested geometry collections.": { + "gis_tests.geoapp.tests.SaveLoadTests." + "test_geometrycollectionfield_default_max_ignored_on_read", + }, } ) return skips diff --git a/django/contrib/gis/db/backends/oracle/operations.py b/django/contrib/gis/db/backends/oracle/operations.py index 918ab7ce6412..9fa1b8da994b 100644 --- a/django/contrib/gis/db/backends/oracle/operations.py +++ b/django/contrib/gis/db/backends/oracle/operations.py @@ -244,7 +244,10 @@ def get_geometry_converter(self, expression): def converter(value, expression, connection): if value is not None: - geom = GEOSGeometryBase(read(memoryview(value.read())), geom_class) + geom = GEOSGeometryBase( + read(memoryview(value.read()), max_geom_collections=None), + geom_class, + ) if srid: geom.srid = srid return geom diff --git a/django/contrib/gis/db/backends/postgis/operations.py b/django/contrib/gis/db/backends/postgis/operations.py index 3c5ea7687ff9..dcbb5e18c831 100644 --- a/django/contrib/gis/db/backends/postgis/operations.py +++ b/django/contrib/gis/db/backends/postgis/operations.py @@ -422,9 +422,12 @@ def get_geometry_converter(self, expression): geom_class = expression.output_field.geom_class def converter(value, expression, connection): - if isinstance(value, str): # Coming from hex strings. - value = value.encode("ascii") - return None if value is None else GEOSGeometryBase(read(value), geom_class) + if value is not None: + if isinstance(value, str): # Coming from hex strings. + value = value.encode("ascii") + return GEOSGeometryBase( + read(value, max_geom_collections=None), geom_class + ) return converter diff --git a/django/contrib/gis/db/backends/spatialite/operations.py b/django/contrib/gis/db/backends/spatialite/operations.py index 1cfd56964fd0..e649c25ffa96 100644 --- a/django/contrib/gis/db/backends/spatialite/operations.py +++ b/django/contrib/gis/db/backends/spatialite/operations.py @@ -230,6 +230,9 @@ def get_geometry_converter(self, expression): read = wkb_r().read def converter(value, expression, connection): - return None if value is None else GEOSGeometryBase(read(value), geom_class) + if value is not None: + return GEOSGeometryBase( + read(value, max_geom_collections=None), geom_class + ) return converter diff --git a/django/contrib/gis/db/models/fields.py b/django/contrib/gis/db/models/fields.py index 305a50ee9875..a001c9a72050 100644 --- a/django/contrib/gis/db/models/fields.py +++ b/django/contrib/gis/db/models/fields.py @@ -17,6 +17,7 @@ Point, Polygon, ) +from django.contrib.gis.geos.prototypes.io import MAX_GEOM_COLLECTIONS from django.core.exceptions import ImproperlyConfigured from django.db.models import Field from django.utils.translation import gettext_lazy as _ @@ -214,8 +215,11 @@ def get_prep_value(self, value): if raster: obj = raster elif is_candidate: + max_geom_collections = getattr( + self, "max_geom_collections", MAX_GEOM_COLLECTIONS + ) try: - obj = GEOSGeometry(obj) + obj = GEOSGeometry(obj, max_geom_collections=max_geom_collections) except (TypeError, ValueError) as err: if isinstance(obj, str) and obj.startswith(VSI_FILESYSTEM_PREFIX): raise blocked_err @@ -259,6 +263,7 @@ def __init__( *, extent=(-180.0, -90.0, 180.0, 90.0), tolerance=0.05, + max_geom_collections=MAX_GEOM_COLLECTIONS, **kwargs, ): """ @@ -277,6 +282,10 @@ def __init__( tolerance: Define the tolerance, in meters, to use for the geometry field entry in the `USER_SDO_GEOM_METADATA` table. Defaults to 0.05. + + max_geom_collections: + The maximum number of geometry collections accepted before parsing is + refused, forwarded to the form field. """ # Setting the dimension of the geometry field. self.dim = dim @@ -289,6 +298,10 @@ def __init__( self._extent = extent self._tolerance = tolerance + # Limit on nested/total geometry collections, forwarded to the form + # field to guard against crashes in GEOS from deeply nested input. + self.max_geom_collections = max_geom_collections + super().__init__(verbose_name=verbose_name, **kwargs) def deconstruct(self): @@ -302,6 +315,8 @@ def deconstruct(self): kwargs["extent"] = self._extent if self._tolerance != 0.05: kwargs["tolerance"] = self._tolerance + if self.max_geom_collections != MAX_GEOM_COLLECTIONS: + kwargs["max_geom_collections"] = self.max_geom_collections return name, path, args, kwargs def contribute_to_class(self, cls, name, **kwargs): @@ -319,6 +334,7 @@ def formfield(self, **kwargs): "form_class": self.form_class, "geom_type": self.geom_type, "srid": self.srid, + "max_geom_collections": self.max_geom_collections, **kwargs, } if self.dim > 2 and not getattr( diff --git a/django/contrib/gis/db/models/proxy.py b/django/contrib/gis/db/models/proxy.py index 1b103aad6e4f..9af1505e8624 100644 --- a/django/contrib/gis/db/models/proxy.py +++ b/django/contrib/gis/db/models/proxy.py @@ -43,7 +43,12 @@ def __get__(self, instance, cls=None): else: # Otherwise, a geometry or raster object is built using the field's # contents, and the model's corresponding attribute is set. - geo_obj = self._load_func(geo_value) + try: + max_geoms = self.field.max_geom_collections + except AttributeError: + geo_obj = self._load_func(geo_value) + else: + geo_obj = self._load_func(geo_value, max_geom_collections=max_geoms) setattr(instance, self.field.attname, geo_obj) return geo_obj diff --git a/django/contrib/gis/forms/fields.py b/django/contrib/gis/forms/fields.py index 1d61ea0dddf6..dcc8bb219e25 100644 --- a/django/contrib/gis/forms/fields.py +++ b/django/contrib/gis/forms/fields.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.gis.geos import GEOSException, GEOSGeometry +from django.contrib.gis.geos.prototypes.io import MAX_GEOM_COLLECTIONS from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ @@ -15,6 +16,7 @@ class GeometryField(forms.Field): widget = OpenLayersWidget geom_type = "GEOMETRY" + max_geom_collections = MAX_GEOM_COLLECTIONS default_error_messages = { "required": _("No geometry value provided."), @@ -26,12 +28,20 @@ class GeometryField(forms.Field): ), } - def __init__(self, *, srid=None, geom_type=None, **kwargs): + def __init__( + self, *, srid=None, geom_type=None, max_geom_collections=None, **kwargs + ): self.srid = srid if geom_type is not None: self.geom_type = geom_type + if max_geom_collections is not None: + self.max_geom_collections = max_geom_collections super().__init__(**kwargs) self.widget.attrs["geom_type"] = self.geom_type + # Propagate the limit to the (per-field) widget instance, which does + # the actual parsing. Custom widgets that override deserialize() and + # ignore this attribute still get the default limit via GEOSGeometry. + self.widget.max_geom_collections = self.max_geom_collections def to_python(self, value): """Transform the value to a Geometry object.""" @@ -43,7 +53,9 @@ def to_python(self, value): value = self.widget.deserialize(value) else: try: - value = GEOSGeometry(value) + value = GEOSGeometry( + value, max_geom_collections=self.max_geom_collections + ) except (GEOSException, ValueError, TypeError): value = None if value is None: diff --git a/django/contrib/gis/forms/widgets.py b/django/contrib/gis/forms/widgets.py index 947f25a0bbdd..1a24975fa227 100644 --- a/django/contrib/gis/forms/widgets.py +++ b/django/contrib/gis/forms/widgets.py @@ -4,6 +4,7 @@ from django.contrib.gis.gdal import GDALException from django.contrib.gis.geometry import json_regex from django.contrib.gis.geos import GEOSException, GEOSGeometry +from django.contrib.gis.geos.prototypes.io import MAX_GEOM_COLLECTIONS from django.forms.widgets import Widget logger = logging.getLogger("django.contrib.gis") @@ -19,6 +20,7 @@ class BaseGeometryWidget(Widget): geom_type = "GEOMETRY" map_srid = 4326 display_raw = False + max_geom_collections = MAX_GEOM_COLLECTIONS supports_3d = False template_name = "" # set on subclasses @@ -36,7 +38,7 @@ def serialize(self, value): def deserialize(self, value): try: - return GEOSGeometry(value) + return GEOSGeometry(value, max_geom_collections=self.max_geom_collections) except (GEOSException, GDALException, ValueError, TypeError) as err: logger.error("Error creating geometry from value '%s' (%s)", value, err) return None diff --git a/django/contrib/gis/geos/geometry.py b/django/contrib/gis/geos/geometry.py index cbbecd47b131..06d5b4412565 100644 --- a/django/contrib/gis/geos/geometry.py +++ b/django/contrib/gis/geos/geometry.py @@ -15,7 +15,14 @@ from django.contrib.gis.geos.libgeos import GEOM_PTR, geos_version_tuple from django.contrib.gis.geos.mutable_list import ListMixin from django.contrib.gis.geos.prepared import PreparedGeometry -from django.contrib.gis.geos.prototypes.io import ewkb_w, wkb_r, wkb_w, wkt_r, wkt_w +from django.contrib.gis.geos.prototypes.io import ( + MAX_GEOM_COLLECTIONS, + ewkb_w, + wkb_r, + wkb_w, + wkt_r, + wkt_w, +) from django.utils.deconstruct import deconstructible from django.utils.encoding import force_bytes, force_str @@ -114,8 +121,8 @@ def __setstate__(self, state): self.srid = srid @classmethod - def _from_wkb(cls, wkb): - return wkb_r().read(wkb) + def _from_wkb(cls, wkb, max_geom_collections=MAX_GEOM_COLLECTIONS): + return wkb_r().read(wkb, max_geom_collections) @staticmethod def from_ewkt(ewkt): @@ -135,8 +142,8 @@ def from_ewkt(ewkt): return GEOSGeometry(GEOSGeometry._from_wkt(wkt), srid=srid) @staticmethod - def _from_wkt(wkt): - return wkt_r().read(wkt) + def _from_wkt(wkt, max_geom_collections=MAX_GEOM_COLLECTIONS): + return wkt_r().read(wkt, max_geom_collections) @classmethod def from_gml(cls, gml_string): @@ -739,7 +746,9 @@ def closed(self): class GEOSGeometry(GEOSGeometryBase, ListMixin): "A class that, generally, encapsulates a GEOS geometry." - def __init__(self, geo_input, srid=None): + def __init__( + self, geo_input, srid=None, *, max_geom_collections=MAX_GEOM_COLLECTIONS + ): """ The base constructor for GEOS geometry objects. It may take the following inputs: @@ -753,6 +762,10 @@ def __init__(self, geo_input, srid=None): The `srid` keyword specifies the Source Reference Identifier (SRID) number for this Geometry. If not provided, it defaults to None. + + The `max_geom_collections` keyword limits how many nested (WKT) or + total (WKB) geometry collections the input may contain before parsing + is refused, guarding against segfaults from deeply nested input. """ input_srid = None if isinstance(geo_input, bytes): @@ -763,10 +776,10 @@ def __init__(self, geo_input, srid=None): # Handle WKT input. if wkt_m["srid"]: input_srid = int(wkt_m["srid"]) - g = self._from_wkt(force_bytes(wkt_m["wkt"])) + g = self._from_wkt(force_bytes(wkt_m["wkt"]), max_geom_collections) elif hex_regex.match(geo_input): # Handle HEXEWKB input. - g = wkb_r().read(force_bytes(geo_input)) + g = wkb_r().read(force_bytes(geo_input), max_geom_collections) elif json_regex.match(geo_input): # Handle GeoJSON input. ogr = gdal.OGRGeometry.from_json(geo_input) @@ -779,7 +792,7 @@ def __init__(self, geo_input, srid=None): g = geo_input elif isinstance(geo_input, memoryview): # When the input is a memoryview (WKB). - g = wkb_r().read(geo_input) + g = wkb_r().read(geo_input, max_geom_collections) elif isinstance(geo_input, GEOSGeometry): g = capi.geom_clone(geo_input.ptr) else: diff --git a/django/contrib/gis/geos/prototypes/io.py b/django/contrib/gis/geos/prototypes/io.py index e86d82ad0943..5e08d1450165 100644 --- a/django/contrib/gis/geos/prototypes/io.py +++ b/django/contrib/gis/geos/prototypes/io.py @@ -1,3 +1,4 @@ +import re import threading from ctypes import POINTER, Structure, byref, c_byte, c_char_p, c_int, c_size_t @@ -15,6 +16,7 @@ from django.contrib.gis.geos.prototypes.geom import c_uchar_p, geos_char_p from django.utils.encoding import force_bytes from django.utils.functional import SimpleLazyObject +from django.utils.regex_helper import _lazy_re_compile # ### The WKB/WKT Reader/Writer structures and pointers ### @@ -145,6 +147,58 @@ def __init__(self): # ### Base WKB/WKT Reading and Writing objects ### +# Sits just under PostGIS's effective ceiling: liblwgeom's LW_PARSER_MAX_DEPTH +# is 200 and counts the leaf geometry, so PostGIS rejects at 199 nested +# collections. 198 keeps Django's guard below that (and far below the GEOS +# segfault threshold) so it rejects before any backend supporting nested +# geometries does. (Oracle and MariaDB don't support nesting.) +MAX_GEOM_COLLECTIONS = 198 + +# GEOS accepts any amount of whitespace around the optional dimension marker, +# so the separators must be \s*, not \s? or \s+. The root variants also allow +# leading whitespace, which GEOS skips before the geometry type. +_WKT_COLLECTION_START_RE = _lazy_re_compile( + r"\bGEOMETRYCOLLECTION(?:\s*(?:ZM|Z|M))?\s*\(", + re.IGNORECASE, +) +_WKT_COLLECTION_START_BYTES_RE = _lazy_re_compile( + rb"\bGEOMETRYCOLLECTION(?:\s*(?:ZM|Z|M))?\s*\(", + re.IGNORECASE, +) +_WKT_COLLECTION_ROOT_RE = _lazy_re_compile( + r"\s*\bGEOMETRYCOLLECTION(?:\s*(?:ZM|Z|M))?\s*\(", + re.IGNORECASE, +) +_WKT_COLLECTION_ROOT_BYTES_RE = _lazy_re_compile( + rb"\s*\bGEOMETRYCOLLECTION(?:\s*(?:ZM|Z|M))?\s*\(", + re.IGNORECASE, +) + + +def _build_collection_header_re(): + """GEOS normalizes WKB types using: (type_code & 0xFFFF) % 1000 + + Therefore, every low 16-bit value congruent to 7 modulo 1000 is interpreted + as a GeometryCollection. Upper 16 bits may contain arbitrary EWKB flags. + """ + low_types = range(7, 0x10000, 1000) + little_endian_types = b"|".join( + re.escape(type_code.to_bytes(2, "little")) for type_code in low_types + ) + big_endian_types = b"|".join( + re.escape(type_code.to_bytes(2, "big")) for type_code in low_types + ) + return _lazy_re_compile( + rb"(?=(" + rb"\x01(?:" + little_endian_types + rb")[\x00-\xff]{2}" + rb"|" + rb"\x00[\x00-\xff]{2}(?:" + big_endian_types + rb")" + rb"))" + ) + + +_COLLECTION_HEADER_RE = _build_collection_header_re() + # Non-public WKB/WKT reader classes for internal use because # their `read` methods return _pointers_ instead of GEOSGeometry @@ -154,9 +208,48 @@ class _WKTReader(IOBase): ptr_type = WKT_READ_PTR destructor = wkt_reader_destroy - def read(self, wkt): + def limit(self, wkt, max_geom_collections): + if max_geom_collections is None: + return + if isinstance(wkt, str): + pattern = _WKT_COLLECTION_START_RE + root_pattern = _WKT_COLLECTION_ROOT_RE + open_paren = "(" + close_paren = ")" + else: + pattern = _WKT_COLLECTION_START_BYTES_RE + root_pattern = _WKT_COLLECTION_ROOT_BYTES_RE + open_paren = ord("(") + close_paren = ord(")") + if root_pattern.match(wkt) is None: + # Fast path: If the beginning does not match GEOMETRYCOLLECTION(, + # then GEOS rejects early (no need to limit): + # GEOS_ERROR: countered : 'GEOMETRYCOLLECTION' + return + collection_starts = {match.end() - 1 for match in pattern.finditer(wkt)} + # Nesting depth can't exceed the total number of collections, so if the + # total is already within the limit, there is nothing to walk. + if len(collection_starts) <= max_geom_collections: + return + collection_depth = 0 + parentheses = [] + for index, char in enumerate(wkt): + if char == open_paren: + is_collection = index in collection_starts + parentheses.append(is_collection) + if is_collection: + collection_depth += 1 + elif char == close_paren and parentheses: + if parentheses.pop(): + collection_depth -= 1 + if collection_depth > max_geom_collections: + msg = "WKT contains too many possible GeometryCollections." + raise ValueError(msg) + + def read(self, wkt, max_geom_collections=MAX_GEOM_COLLECTIONS): if not isinstance(wkt, (bytes, str)): raise TypeError(f"'wkt' must be bytes or str (got {wkt!r} instead).") + self.limit(wkt, max_geom_collections) return wkt_reader_read(self.ptr, force_bytes(wkt)) @@ -165,21 +258,65 @@ class _WKBReader(IOBase): ptr_type = WKB_READ_PTR destructor = wkb_reader_destroy - def read(self, wkb): + def limit_wkb(self, wkb, max_geom_collections): + if max_geom_collections is None: + return + for count, _ in enumerate(_COLLECTION_HEADER_RE.finditer(wkb), 1): + if count > max_geom_collections: + msg = "WKB contains too many possible GeometryCollections." + raise ValueError(msg) + + def limit_hex(self, wkb, max_geom_collections): + if max_geom_collections is None: + return + + def _byteswap_uint32(value): + return ( + ((value & 0x000000FF) << 24) + | ((value & 0x0000FF00) << 8) + | ((value & 0x00FF0000) >> 8) + | ((value & 0xFF000000) >> 24) + ) + + count = 0 + for index in range(0, len(wkb) - 9, 2): + byte_order = wkb[index : index + 2] + if byte_order not in (b"00", b"01"): + continue + try: + geometry_type = int(wkb[index + 2 : index + 10], 16) + except ValueError: + continue + geometry_type = _byteswap_uint32(geometry_type) + # Match GEOS WKBReader's geometry-type normalization. + if (geometry_type & 0xFFFF) % 1000 == 7: # GeometryCollection. + count += 1 + if count > max_geom_collections: + msg = "WKB contains too many possible GeometryCollections." + raise ValueError(msg) + + def read(self, wkb, max_geom_collections=MAX_GEOM_COLLECTIONS): "Return a _pointer_ to C GEOS Geometry object from the given WKB." + limiter = self.limit_hex + reader = wkb_reader_read_hex + if isinstance(wkb, memoryview): - wkb_s = bytes(wkb) - return wkb_reader_read(self.ptr, wkb_s, len(wkb_s)) - elif isinstance(wkb, bytes): - return wkb_reader_read_hex(self.ptr, wkb, len(wkb)) + wkb = bytes(wkb) + limiter = self.limit_wkb + reader = wkb_reader_read elif isinstance(wkb, str): - wkb_s = wkb.encode() - return wkb_reader_read_hex(self.ptr, wkb_s, len(wkb_s)) - else: + wkb = wkb.encode() + elif not isinstance(wkb, bytes): raise TypeError( f"'wkb' must be bytes, str or memoryview (got {wkb!r} instead)." ) + # Limit nested geometry collections. Should become unnecessary when + # GEOS 3.15.0 is the minimum supported version. See: + # https://github.com/libgeos/geos/commit/8b8b3da7a3d9fb8953ff60bc49aa0320d51ae45c + limiter(wkb, max_geom_collections) + return reader(self.ptr, wkb, len(wkb)) + def default_trim_value(): """ diff --git a/docs/ref/contrib/gis/forms-api.txt b/docs/ref/contrib/gis/forms-api.txt index c65fd78692b3..8af76d5d1bc3 100644 --- a/docs/ref/contrib/gis/forms-api.txt +++ b/docs/ref/contrib/gis/forms-api.txt @@ -36,6 +36,30 @@ GeoDjango form fields take the following optional arguments. be set up depending on the field class. It matches the OpenGIS standard geometry name. +``max_geom_collections`` +------------------------ + +.. attribute:: Field.max_geom_collections + + .. versionadded:: 5.2.17 + + The maximum number of geometry collections the field accepts before + refusing to parse the input and raising a + :exc:`~django.core.exceptions.ValidationError`. This guards against crashes + in the underlying GEOS library when parsing deeply nested + ``GEOMETRYCOLLECTION`` input. It defaults to ``198``. + + The limit is applied differently depending on the input format: for + well-known text (WKT) it bounds the nesting *depth*, while for well-known + binary (WKB and hex-encoded WKB) it bounds the *total* number of geometry + collections (both breadth and depth). As a result, the same value may + accept a wide, shallow collection as WKT but reject it as WKB. For GeoJSON, + the limit is not applied at all, since GDAL parses that input type instead. + + Increase this value (or set to ``None``) only if you must accept + legitimately deep geometries, since doing so reduces protection against + fatal errors. + Form field classes ================== diff --git a/docs/ref/contrib/gis/geos.txt b/docs/ref/contrib/gis/geos.txt index 31ab8f10085b..502e59fb6a5f 100644 --- a/docs/ref/contrib/gis/geos.txt +++ b/docs/ref/contrib/gis/geos.txt @@ -208,10 +208,12 @@ Geometry Objects ``GEOSGeometry`` ---------------- -.. class:: GEOSGeometry(geo_input, srid=None) +.. class:: GEOSGeometry(geo_input, srid=None, *, max_geom_collections=198) :param geo_input: Geometry input value (string or :class:`memoryview`) :param srid: spatial reference identifier + :param max_geom_collections: maximum number of nested (WKT) or total (WKB) + geometry collections accepted before parsing is refused :type srid: int This is the base class for all GEOS geometry objects. It initializes on the @@ -249,6 +251,10 @@ WKB / EWKB ``memoryview`` For the GeoJSON format, the SRID is set based on the ``crs`` member. If ``crs`` isn't provided, the SRID defaults to 4326. +.. versionchanged:: 5.2.17 + + The ``max_geom_collections`` parameter was added. + .. classmethod:: GEOSGeometry.from_gml(gml_string) Constructs a :class:`GEOSGeometry` from the given GML string. diff --git a/docs/ref/contrib/gis/model-api.txt b/docs/ref/contrib/gis/model-api.txt index 54c5c7843588..765735d172b2 100644 --- a/docs/ref/contrib/gis/model-api.txt +++ b/docs/ref/contrib/gis/model-api.txt @@ -223,6 +223,20 @@ details. Geography support is limited to PostGIS and will force the SRID to be 4326. +``max_geom_collections`` +------------------------ + +.. attribute:: GeometryField.max_geom_collections + +.. versionadded:: 5.2.17 + +This option is forwarded to the :attr:`form field +` generated for this model +field, bounding how many geometry collections may be contained in submitted +WKB/WKT inputs before raising :exc:`ValueError`. Since spatial field +assignments are lazy, it is also checked when values are accessed, e.g. when +saving an instance, but not when read from a database. It defaults to ``198``. + .. _geography-type: Geography Type diff --git a/docs/releases/5.2.17.txt b/docs/releases/5.2.17.txt index 843feefaa12d..a888b769c8e3 100644 --- a/docs/releases/5.2.17.txt +++ b/docs/releases/5.2.17.txt @@ -55,3 +55,27 @@ now rejected before the cached lookup. This issue has severity "low" according to the :ref:`Django security policy `. + +CVE-2026-15830: Potential denial-of-service vulnerability via nested geometry collections +========================================================================================= + +:class:`~django.contrib.gis.geos.GEOSGeometry` was subject to a potential +denial-of-service attack when provided deeply nested ``GEOMETRYCOLLECTION`` +objects, leading to a segmentation fault in GEOS. A maximum depth of 198 +``GEOMETRYCOLLECTION``\s is now enforced for the well-known text (WKT) format, +and a maximum number of 198 ``GEOMETRYCOLLECTION``\s in total (breadth and +depth) is enforced for well-known binary (WKB). + +:ref:`Lookups against spatial fields ` and the +:class:`~django.contrib.gis.forms.GeometryField` form field were also affected. + +The limit can be customized through the new ``max_geom_collections`` argument, +available on :class:`~django.contrib.gis.geos.GEOSGeometry`, the +:attr:`form field `, +and the :attr:`model field +`. The limit +is not applied to GeoJSON inputs, as they were parsed by GDAL and are not +affected. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. diff --git a/docs/releases/6.0.8.txt b/docs/releases/6.0.8.txt index c31550d89edf..975e18d99caf 100644 --- a/docs/releases/6.0.8.txt +++ b/docs/releases/6.0.8.txt @@ -56,6 +56,30 @@ now rejected before the cached lookup. This issue has severity "low" according to the :ref:`Django security policy `. +CVE-2026-15830: Potential denial-of-service vulnerability via nested geometry collections +========================================================================================= + +:class:`~django.contrib.gis.geos.GEOSGeometry` was subject to a potential +denial-of-service attack when provided deeply nested ``GEOMETRYCOLLECTION`` +objects, leading to a segmentation fault in GEOS. A maximum depth of 198 +``GEOMETRYCOLLECTION``\s is now enforced for the well-known text (WKT) format, +and a maximum number of 198 ``GEOMETRYCOLLECTION``\s in total (breadth and +depth) is enforced for well-known binary (WKB). + +:ref:`Lookups against spatial fields ` and the +:class:`~django.contrib.gis.forms.GeometryField` form field were also affected. + +The limit can be customized through the new ``max_geom_collections`` argument, +available on :class:`~django.contrib.gis.geos.GEOSGeometry`, the +:attr:`form field `, +and the :attr:`model field +`. The limit +is not applied to GeoJSON inputs, as they were parsed by GDAL and are not +affected. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. + Bugfixes ======== diff --git a/tests/gis_tests/geoapp/models.py b/tests/gis_tests/geoapp/models.py index 9dd6e442fa34..d7de7cd867cd 100644 --- a/tests/gis_tests/geoapp/models.py +++ b/tests/gis_tests/geoapp/models.py @@ -113,4 +113,4 @@ class Lines(models.Model): class GeometryCollectionModel(models.Model): - geom = models.GeometryCollectionField() + geom = models.GeometryCollectionField(max_geom_collections=5) diff --git a/tests/gis_tests/geoapp/tests.py b/tests/gis_tests/geoapp/tests.py index 0738db663ec4..1ea87428ce6d 100644 --- a/tests/gis_tests/geoapp/tests.py +++ b/tests/gis_tests/geoapp/tests.py @@ -1,7 +1,7 @@ import json from io import StringIO from pathlib import Path -from unittest import skipIf +from unittest import mock, skipIf from django.contrib.gis import gdal from django.contrib.gis.db.models import Extent, MakeLine, Union, functions @@ -22,7 +22,7 @@ from django.core.management import call_command from django.db import DatabaseError, NotSupportedError, connection from django.db.models import F, OuterRef, Subquery -from django.test import TestCase, skipUnlessDBFeature +from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature from django.test.utils import CaptureQueriesContext from ..data.rasters.textrasters import JSON_RASTER @@ -318,6 +318,49 @@ def test_geometrycollectionfield(self): obj.refresh_from_db() self.assertIs(obj.geom.equals(geom), True) + def test_geometrycollectionfield_max(self): + geom = "POINT(0 0)" + for _ in range(6): + geom = f"GEOMETRYCOLLECTION({geom})" + msg = "WKT contains too many possible GeometryCollections." + with self.assertRaisesMessage(ValueError, msg): + GeometryCollectionModel.objects.create(geom=geom) + with self.assertRaisesMessage(ValueError, msg): + GeometryCollectionModel.objects.bulk_create( + [GeometryCollectionModel(geom=geom), GeometryCollectionModel(geom=geom)] + ) + + def test_geometrycollectionfield_default_max_ignored_on_read(self): + geom = "POINT(0 0)" + for _ in range(5): + geom = f"GEOMETRYCOLLECTION({geom})" + obj = GeometryCollectionModel.objects.create(geom=geom) + with ( + mock.patch( + "django.contrib.gis.geos.prototypes.io._WKBReader.limit_hex" + ) as hex_limit_mock, + mock.patch( + "django.contrib.gis.geos.prototypes.io._WKBReader.limit_wkb" + ) as wkb_limit_mock, + ): + obj.refresh_from_db() + limit_mock = hex_limit_mock if hex_limit_mock.call_count else wkb_limit_mock + limit_mock.assert_called_once() + max_geom_collections = limit_mock.call_args.args[1] + self.assertIsNone(max_geom_collections) + + +class ValidationTests(SimpleTestCase): + def test_geometrycollectionfield_max(self): + geom = "POINT(0 0)" + for _ in range(6): + geom = f"GEOMETRYCOLLECTION({geom})" + obj = GeometryCollectionModel(geom=geom) + msg = "WKT contains too many possible GeometryCollections." + # Spatial fields do not re-raise ValueError as ValidationError. + with self.assertRaisesMessage(ValueError, msg): + obj.full_clean() + class GeoLookupTest(TestCase): fixtures = ["initial"] @@ -785,6 +828,15 @@ def test_lookup_allows_geos_geometry_string(self): # Just get SQL to avoid gating on connection.supports_raster. City.objects.filter(point__contained=geojson).query + @skipUnlessGISLookup("exact") + def test_lookup_against_nested_geometry_collection(self): + geom = "POINT(0 0)" + for _ in range(6): + geom = f"GEOMETRYCOLLECTION({geom})" + msg = "WKT contains too many possible GeometryCollections." + with self.assertRaisesMessage(ValueError, msg): + GeometryCollectionModel.objects.filter(geom=geom) + class GeoQuerySetTest(TestCase): # TODO: GeoQuerySet is removed, organize these test better. diff --git a/tests/gis_tests/geos_tests/test_geos_limit.py b/tests/gis_tests/geos_tests/test_geos_limit.py new file mode 100644 index 000000000000..1904436eaf08 --- /dev/null +++ b/tests/gis_tests/geos_tests/test_geos_limit.py @@ -0,0 +1,176 @@ +import struct + +from django.contrib.gis.geos import GEOSGeometry, WKTReader +from django.contrib.gis.geos.error import GEOSException +from django.contrib.gis.geos.prototypes.io import MAX_GEOM_COLLECTIONS +from django.test import SimpleTestCase + + +class GEOSLimitTest(SimpleTestCase): + def _generate_geometry_collection_payloads(self, depth): + def point(endian="<", type_code=1, dims=2, srid=None): + marker = b"\x01" if endian == "<" else b"\x00" + head = marker + struct.pack(f"{endian}I", type_code) + if srid is not None: + head += struct.pack(f"{endian}I", srid) + return head + struct.pack(f"{endian}{'d' * dims}", *((0.0,) * dims)) + + def layer(endian="<", type_code=7, srid=None): + marker = b"\x01" if endian == "<" else b"\x00" + head = marker + struct.pack(f"{endian}I", type_code) + if srid is not None: + head += struct.pack(f"{endian}I", srid) + return head + struct.pack(f"{endian}I", 1) + + def wkb(coll=7, child=1, dims=2, srid=None): + return layer(type_code=coll, srid=srid) * depth + point( + type_code=child, dims=dims, srid=srid + ) + + # (label, collection type code, child type code, child dims, srid, ... + # check_geos). + variants = [ + ("ISO WKB Z", 1007, 1001, 3, None, True), + ("ISO WKB M", 2007, 2001, 3, None, True), + ("ISO WKB ZM", 3007, 3001, 4, None, True), + ("EWKB Z", 0x80000007, 0x80000001, 3, None, True), + ("EWKB M", 0x40000007, 0x40000001, 3, None, True), + ("EWKB ZM", 0xC0000007, 0xC0000001, 4, None, True), + ("EWKB SRID", 0x20000007, 0x20000001, 2, 4326, True), + ("EWKB Z and SRID", 0xA0000007, 0xA0000001, 3, 4326, True), + ("EWKB ZM and SRID", 0xE0000007, 0xE0000001, 4, 4326, True), + # Undoc'd high-bit combinations accepted by some GEOS versions. + # These only verify that Django's limiter recognizes the normalized + # GeometryCollection type before GEOS parses the payload. + ("EWKB BBOX", 0x10000007, 0x10000001, 2, None, False), + ("EWKB BBOX and Z", 0x90000007, 0x90000001, 3, None, False), + ("EWKB BBOX and M", 0x50000007, 0x50000001, 3, None, False), + ("EWKB BBOX and ZM", 0xD0000007, 0xD0000001, 4, None, False), + ("EWKB BBOX and SRID", 0x30000007, 0x30000001, 2, 4326, False), + ("EWKB BBOX, Z, and SRID", 0xB0000007, 0xB0000001, 3, 4326, False), + ("EWKB BBOX, M, and SRID", 0x70000007, 0x70000001, 3, 4326, False), + ("EWKB BBOX, ZM, and SRID", 0xF0000007, 0xF0000001, 4, 4326, False), + ("EWKB unknown high flag", 0x01000007, 0x01000001, 2, None, False), + ] + binary = [ + (layer() * depth + point(), "little-endian WKB", True), + (layer(endian=">") * depth + point(endian=">"), "big-endian WKB", True), + ( + b"".join(layer(endian="<" if i % 2 == 0 else ">") for i in range(depth)) + + point(endian=">"), + "mixed-endian WKB", + True, + ), + ] + binary += [(wkb(c, ch, d, s), label, cg) for label, c, ch, d, s, cg in variants] + + payloads = [] + for data, label, check_geos in binary: + payloads += [ + (data.hex().upper(), f"{label}, uppercase hex string", check_geos), + (data.hex().encode("ascii"), f"{label}, lower hex bytes", check_geos), + (memoryview(data), f"{label}, memoryview", check_geos), + ] + wkt = "GEOMETRYCOLLECTION(" * depth + "POINT(0 0)" + ")" * depth + payloads += [(wkt, "WKT", True), (wkt.encode("ascii"), "WKT bytes", True)] + return payloads + + def test_geometry_collection_limit_exceeded(self): + msg = "contains too many possible GeometryCollections." + payloads = self._generate_geometry_collection_payloads(depth=6) + for payload, label, check_geos in payloads: + with self.subTest(payload=label): + with self.assertRaisesMessage(ValueError, msg): + GEOSGeometry(payload, max_geom_collections=5) + # Valid cases. + if check_geos: + GEOSGeometry(payload, max_geom_collections=6) + GEOSGeometry(payload, max_geom_collections=None) + + def test_wkt_geometry_collection_flat(self): + def wkt_payload_no_nesting(num_points): + # Many parentheses, but only one collection level. + return ( + "GEOMETRYCOLLECTION(" + + ",".join("POINT(0 0)" for _ in range(num_points)) + + ")" + ) + + GEOSGeometry(wkt_payload_no_nesting(num_points=5), max_geom_collections=1) + + def test_wkt_mixed_case_and_inner_whitespace_is_limited(self): + two_collections = ( + "GEOMETRYCOLLECTION ( " + "geometrycollection ( " + "POINT (0 0), POINT(1 1)" + ") )" + ) + msg = "WKT contains too many possible GeometryCollections." + with self.assertRaisesMessage(ValueError, msg): + GEOSGeometry(two_collections, max_geom_collections=1) + GEOSGeometry(two_collections, max_geom_collections=2) + + def test_from_ewkt_leading_whitespace_is_limited(self): + # from_ewkt() hands the part after the SRID to the low-level reader, + # so leading whitespace never passes through wkt_regex. + wkt = " " + "GEOMETRYCOLLECTION(" * 200 + "POINT(0 0)" + ")" * 200 + msg = "WKT contains too many possible GeometryCollections." + for value in wkt, wkt.encode(): + with self.subTest(value=value): + with self.assertRaisesMessage(ValueError, msg): + GEOSGeometry.from_ewkt(value) + + def test_wkt_dimension_marker_whitespace_is_limited(self): + def two_collections(separator): + collection = f"GEOMETRYCOLLECTION{separator}ZM" + return f"{collection}({collection}(POINT ZM (0 0 0 0)))" + + msg = "WKT contains too many possible GeometryCollections." + # GEOS accepts any amount of whitespace before the dimension marker. + for separator in "", " ", " ": + with self.subTest(separator=separator): + value = two_collections(separator) + with self.assertRaisesMessage(ValueError, msg): + GEOSGeometry(value, max_geom_collections=1) + GEOSGeometry(value, max_geom_collections=2) + + def test_wkt_reader_whitespace_is_limited(self): + # WKTReader.read() takes str and bytes directly, so the whitespace + # GEOS tolerates but wkt_regex rejects reaches the limiter. + reader = WKTReader() + msg = "WKT contains too many possible GeometryCollections." + for prefix in "", " ", "\t\n ": + for separator in "", " ", " ", "\t", "\n", " \t\n ": + collection = f"GEOMETRYCOLLECTION{separator}ZM" + point = "POINT ZM (0 0 0 0)" + depth = MAX_GEOM_COLLECTIONS + 1 + over = prefix + f"{collection}(" * depth + point + ")" * depth + with self.subTest(prefix=prefix, separator=separator): + for value in over, over.encode(): + with self.assertRaisesMessage(ValueError, msg): + reader.read(value) + under = f"{prefix}{collection}({point})" + self.assertEqual(reader.read(under).geom_type, "GeometryCollection") + + def test_non_collection_wkt_root_fast_path(self): + def make_geom(depth): + return "GEOMETRYCOLLECTION(" * depth + "POINT(0 0)" + ")" * depth + + invalid_wkt = "POLYGON(" + make_geom(6) + ")" + # Instead of raising a ValueError, a fast path skips the limit and + # depends on GEOS to reject collections found anywhere but the root. + with self.assertRaises(GEOSException): + GEOSGeometry(invalid_wkt, max_geom_collections=5) + + def test_malformed_multi_wkb_child_is_limited(self): + def make_invalid_geom(depth): + point = b"\x01" + struct.pack(" default applies + + fld = forms.GeometryField(max_geom_collections=5, widget=IgnoringWidget) + + def make_geom(depth): + return "GEOMETRYCOLLECTION(" * depth + "POINT(0 0)" + ")" * depth + + # The field's low limit (5) is ignored by the widget... + self.assertIsNotNone(fld.clean(make_geom(6))) + # ...but the default (198) still guards against deeper input. + with self.assertRaises(ValueError): + fld.clean(make_geom(MAX_GEOM_COLLECTIONS + 1)) + def test_null(self): "Testing GeometryField's handling of null (None) geometries." # Form fields, by default, are required (`required=True`) From 47511a21026cdd721d8fbf8571cc079bc38bb46d Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:12:05 -0300 Subject: [PATCH 4/6] Fixed CVE-2026-15920 -- Made display_for_field() validate URLs before rendering admin links. The admin renders URLField values as clickable links on changelists and read-only change forms. The link was built without validating the URL, so a potentially dangerous stored value could be rendered as a link that runs script in a staff member's authenticated session when clicked. The admin renders URLField values as clickable links on changelists and read-only change forms. The link was built without validating the URL, so a stored value using a potentially dangerous value was rendered as a link, which could lead to cross-site scripting in an authenticated admin session. Refs CVE-2019-12308, #36032. Thanks to Egor Saltykov for the report, and Sarah Boyce for reviews. --- django/contrib/admin/utils.py | 12 ++++++++++-- docs/releases/5.2.17.txt | 15 +++++++++++++++ docs/releases/6.0.8.txt | 15 +++++++++++++++ tests/admin_utils/tests.py | 13 +++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/django/contrib/admin/utils.py b/django/contrib/admin/utils.py index 38aa98b6bdb6..768bb56add11 100644 --- a/django/contrib/admin/utils.py +++ b/django/contrib/admin/utils.py @@ -7,8 +7,8 @@ from django.contrib.auth import get_user_model from django.contrib.auth.templatetags.auth import render_password_as_hash -from django.core.exceptions import FieldDoesNotExist -from django.core.validators import EMPTY_VALUES +from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.core.validators import EMPTY_VALUES, URLValidator from django.db import models, router from django.db.models.constants import LOOKUP_SEP from django.db.models.deletion import Collector @@ -464,6 +464,14 @@ def display_for_field(value, field, empty_value_display, avoid_link=False): elif isinstance(field, models.FileField) and value and not avoid_link: return format_html('{}', value.url, value) elif isinstance(field, models.URLField) and value and not avoid_link: + # Only render a clickable link for URLs with a safe scheme, so that a + # potentially dangerous stored value is shown as plain text rather than + # an executable link. The check is deliberately independent of the + # field's own validators, which may permit such schemes. + try: + URLValidator()(value) + except ValidationError: + return display_for_value(value, empty_value_display) return format_html('{}', value, value) elif isinstance(field, models.JSONField) and value: try: diff --git a/docs/releases/5.2.17.txt b/docs/releases/5.2.17.txt index a888b769c8e3..b80f5818a9b4 100644 --- a/docs/releases/5.2.17.txt +++ b/docs/releases/5.2.17.txt @@ -79,3 +79,18 @@ affected. This issue has severity "moderate" according to the :ref:`Django security policy `. + +CVE-2026-15920: Potential cross-site scripting via ``URLField`` values in the admin +=================================================================================== + +The admin renders :class:`~django.db.models.URLField` values as clickable links +on changelist views and read-only fields. The link was generated without +validating the value as a safe URL, so a stored value using a potentially +dangerous scheme was rendered as a link. + +``URLField`` values shown via ``display_for_field`` are now validated using +:class:`~django.core.validators.URLValidator` before a link is rendered, and +displayed as plain text if validation is failed. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. diff --git a/docs/releases/6.0.8.txt b/docs/releases/6.0.8.txt index 975e18d99caf..404f9a250400 100644 --- a/docs/releases/6.0.8.txt +++ b/docs/releases/6.0.8.txt @@ -80,6 +80,21 @@ affected. This issue has severity "moderate" according to the :ref:`Django security policy `. +CVE-2026-15920: Potential cross-site scripting via ``URLField`` values in the admin +=================================================================================== + +The admin renders :class:`~django.db.models.URLField` values as clickable links +on changelist views and read-only fields. The link was generated without +validating the value as a safe URL, so a stored value using a potentially +dangerous scheme was rendered as a link. + +``URLField`` values shown via ``display_for_field`` are now validated using +:class:`~django.core.validators.URLValidator` before a link is rendered, and +displayed as plain text if validation is failed. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. + Bugfixes ======== diff --git a/tests/admin_utils/tests.py b/tests/admin_utils/tests.py index a2426d9f04d4..1ec64d031c9b 100644 --- a/tests/admin_utils/tests.py +++ b/tests/admin_utils/tests.py @@ -240,6 +240,19 @@ def test_url_display_for_field(self): expected = 'http://example.com' self.assertHTMLEqual(display_value, expected) + def test_url_display_for_field_invalid_url(self): + # An invalid URL, such as one with an unsafe scheme, is rendered as + # plain text instead of a clickable link. + model_field = models.URLField() + for value in [ + "javascript:alert(1)", + "data:text/html,", + ]: + with self.subTest(value=value): + display_value = display_for_field(value, model_field, self.empty_value) + self.assertNotIn(" Date: Tue, 4 Aug 2026 12:13:41 -0300 Subject: [PATCH 5/6] Added CVE-2026-15307, CVE-2026-15337, CVE-2026-15830, and CVE-2026-15920 to security archive. --- docs/releases/security.txt | 44 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/docs/releases/security.txt b/docs/releases/security.txt index 50a87f6fc84d..d0babde13383 100644 --- a/docs/releases/security.txt +++ b/docs/releases/security.txt @@ -36,6 +36,50 @@ Issues under Django's security process All security issues have been handled under versions of Django's security process. These are listed below. +August 4, 2026 - :cve:`2026-15307` +---------------------------------- + +Server-side file-write and request forgery via spatial lookups. +`Full description +`__ + +* Django 6.1 :commit:`(patch) <39b3e2d0c743a338def6c473086ebc06865e86b6>` +* Django 6.0 :commit:`(patch) <208f80cb682868b584ed0a78f23e4ba6304212aa>` +* Django 5.2 :commit:`(patch) <115ffd0463a765ab1cc93de18e94b5459b8a300e>` + +August 4, 2026 - :cve:`2026-15337` +---------------------------------- + +Potential denial-of-service vulnerability in ``check_for_language()``. +`Full description +`__ + +* Django 6.1 :commit:`(patch) <5b3523d29be25948e1dd90b3863a002f00fc865f>` +* Django 6.0 :commit:`(patch) <224dbc832586ad5cfb0237c2ff30d14baeaddc6f>` +* Django 5.2 :commit:`(patch) ` + +August 4, 2026 - :cve:`2026-15830` +---------------------------------- + +Potential denial-of-service vulnerability via nested geometry collections. +`Full description +`__ + +* Django 6.1 :commit:`(patch) <9e4a3f186b6b07b483bfd9195ea06734663fcd06>` +* Django 6.0 :commit:`(patch) <6af5da31775417c610dbf9c3f1b5b8333d42daf6>` +* Django 5.2 :commit:`(patch) ` + +August 4, 2026 - :cve:`2026-15920` +---------------------------------- + +Potential cross-site scripting via ``URLField`` values in the admin. +`Full description +`__ + +* Django 6.1 :commit:`(patch) <5a260d309a4c8010c2ebda24eb758a5d95e2508a>` +* Django 6.0 :commit:`(patch) <13debb622a32720bda1bccda7622fd14fbf3931b>` +* Django 5.2 :commit:`(patch) ` + July 7, 2026 - :cve:`2026-48588` -------------------------------- From f00270d05e3510f2edc8ae012418d1dfe34d99f5 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Thu, 23 Jul 2026 11:41:56 -0400 Subject: [PATCH 6/6] Refs #36735 -- Added DatabaseFeatures.supports_uuid4_function_in_default feature flag. Oracle 23.9 supported UUID() in SELECT contexts, but use in column defaults fails until 23.26.2.0 with: django.db.utils.DatabaseError: ORA-04044: procedure, function, package, or type is not allowed here --- django/db/backends/base/features.py | 1 + django/db/backends/mysql/features.py | 4 ++++ django/db/backends/oracle/features.py | 4 ++++ django/db/backends/postgresql/features.py | 3 +++ django/db/backends/sqlite3/features.py | 3 +++ tests/inline_formsets/models.py | 4 ++-- tests/inline_formsets/tests.py | 4 +++- 7 files changed, 20 insertions(+), 3 deletions(-) diff --git a/django/db/backends/base/features.py b/django/db/backends/base/features.py index c9e78b574650..9f00dcfc3537 100644 --- a/django/db/backends/base/features.py +++ b/django/db/backends/base/features.py @@ -450,6 +450,7 @@ class BaseDatabaseFeatures: ] supports_uuid4_function = False + supports_uuid4_function_in_default = False supports_uuid7_function = False supports_uuid7_function_shift = False diff --git a/django/db/backends/mysql/features.py b/django/db/backends/mysql/features.py index e7970cb063fa..24a8675c01cb 100644 --- a/django/db/backends/mysql/features.py +++ b/django/db/backends/mysql/features.py @@ -237,6 +237,10 @@ def supports_uuid4_function(self): return self.connection.mysql_version >= (11, 7) return False + supports_uuid4_function_in_default = property( + operator.attrgetter("supports_uuid4_function") + ) + @cached_property def supports_uuid7_function(self): if self.connection.mysql_is_mariadb: diff --git a/django/db/backends/oracle/features.py b/django/db/backends/oracle/features.py index 81bb28ea2a85..3ca7894b2f63 100644 --- a/django/db/backends/oracle/features.py +++ b/django/db/backends/oracle/features.py @@ -235,6 +235,10 @@ def supports_tuple_lookups(self): def supports_uuid4_function(self): return self.connection.oracle_version >= (23, 9) + @cached_property + def supports_uuid4_function_in_default(self): + return self.connection.oracle_version >= (23, 26, 2) + @cached_property def supports_stored_generated_columns(self): return self.connection.oracle_version >= (23, 7) diff --git a/django/db/backends/postgresql/features.py b/django/db/backends/postgresql/features.py index b4a257547522..fa23aea34579 100644 --- a/django/db/backends/postgresql/features.py +++ b/django/db/backends/postgresql/features.py @@ -190,3 +190,6 @@ def is_postgresql_18(self): supports_virtual_generated_columns = property( operator.attrgetter("is_postgresql_18") ) + supports_uuid4_function_in_default = property( + operator.attrgetter("supports_uuid4_function") + ) diff --git a/django/db/backends/sqlite3/features.py b/django/db/backends/sqlite3/features.py index ed24341b5e6c..e993666de5ba 100644 --- a/django/db/backends/sqlite3/features.py +++ b/django/db/backends/sqlite3/features.py @@ -172,3 +172,6 @@ def supports_json_field(self): can_introspect_json_field = property(operator.attrgetter("supports_json_field")) has_json_object_function = property(operator.attrgetter("supports_json_field")) + supports_uuid4_function_in_default = property( + operator.attrgetter("supports_uuid4_function") + ) diff --git a/tests/inline_formsets/models.py b/tests/inline_formsets/models.py index ccada27e4c88..af6680cec22f 100644 --- a/tests/inline_formsets/models.py +++ b/tests/inline_formsets/models.py @@ -27,7 +27,7 @@ class ParentUUIDPk(models.Model): class Meta: required_db_features = { - "supports_uuid4_function", + "supports_uuid4_function_in_default", "supports_expression_defaults", } @@ -38,7 +38,7 @@ class ChildUUIDPk(models.Model): class Meta: required_db_features = { - "supports_uuid4_function", + "supports_uuid4_function_in_default", "supports_expression_defaults", } diff --git a/tests/inline_formsets/tests.py b/tests/inline_formsets/tests.py index 78456ec51487..3ef1e6e04d25 100644 --- a/tests/inline_formsets/tests.py +++ b/tests/inline_formsets/tests.py @@ -113,7 +113,9 @@ def test_save_new(self): obj.save() self.assertEqual(school.child_set.count(), 1) - @skipUnlessDBFeature("supports_uuid4_function", "supports_expression_defaults") + @skipUnlessDBFeature( + "supports_uuid4_function_in_default", "supports_expression_defaults" + ) def test_add_form_uuid_pk(self): ChildFormSet = inlineformset_factory(ParentUUIDPk, ChildUUIDPk, fields=["name"]) data = {