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

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

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

1145 lines
50 KiB
Diff

From 72d8819eef87adbcbbedc80084a2e6652447619f Mon Sep 17 00:00:00 2001
From: Jacob Walls <jacobtylerwalls@gmail.com>
Date: Wed, 15 Jul 2026 15:39:27 -0400
Subject: [PATCH] [5.2.x] 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.
Backport of d2e59b77fe18de318a8272c2a7bbc798d84d1d0d from main.
CVE: CVE-2026-15830
Upstream-Status: Backport [https://github.com/django/django/commit/ba80833fa656dd09660b97c4429331067db1b080]
Backport Changes:
- Dropped the docs/releases/5.2.17.txt file as
current version for Scarthgap is 5.0.14
(cherry picked from commit ba80833fa656dd09660b97c4429331067db1b080)
Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
---
.../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 | 161 ++++++++++++++--
docs/ref/contrib/gis/forms-api.txt | 24 +++
docs/ref/contrib/gis/geos.txt | 8 +-
docs/ref/contrib/gis/model-api.txt | 14 ++
tests/gis_tests/geoapp/models.py | 4 +
tests/gis_tests/geoapp/tests.py | 59 +++++-
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 +++++
21 files changed, 629 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 cd99420374..78e58617ec 100644
--- a/django/contrib/gis/db/backends/mysql/features.py
+++ b/django/contrib/gis/db/backends/mysql/features.py
@@ -19,3 +19,17 @@ class DatabaseFeatures(BaseSpatialFeatures, MySQLDatabaseFeatures):
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 886db605cd..4d6103dc5b 100644
--- a/django/contrib/gis/db/backends/mysql/operations.py
+++ b/django/contrib/gis/db/backends/mysql/operations.py
@@ -122,7 +122,9 @@ class MySQLOperations(BaseSpatialOperations, DatabaseOperations):
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 f346d93573..16279b8a1e 100644
--- a/django/contrib/gis/db/backends/oracle/features.py
+++ b/django/contrib/gis/db/backends/oracle/features.py
@@ -23,6 +23,10 @@ class DatabaseFeatures(BaseSpatialFeatures, OracleDatabaseFeatures):
"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 eb86dc39de..59366eadc0 100644
--- a/django/contrib/gis/db/backends/oracle/operations.py
+++ b/django/contrib/gis/db/backends/oracle/operations.py
@@ -236,7 +236,10 @@ class OracleOperations(BaseSpatialOperations, DatabaseOperations):
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 b68db377f8..381e408bc7 100644
--- a/django/contrib/gis/db/backends/postgis/operations.py
+++ b/django/contrib/gis/db/backends/postgis/operations.py
@@ -413,9 +413,12 @@ class PostGISOperations(BaseSpatialOperations, DatabaseOperations):
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 d39f7a9e0d..721d9fa047 100644
--- a/django/contrib/gis/db/backends/spatialite/operations.py
+++ b/django/contrib/gis/db/backends/spatialite/operations.py
@@ -223,6 +223,9 @@ class SpatiaLiteOperations(BaseSpatialOperations, DatabaseOperations):
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 15d9ae3c57..d3f9b1b2ff 100644
--- a/django/contrib/gis/db/models/fields.py
+++ b/django/contrib/gis/db/models/fields.py
@@ -17,6 +17,7 @@ from django.contrib.gis.geos import (
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 _
@@ -215,8 +216,11 @@ class BaseSpatialField(Field):
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
@@ -260,6 +264,7 @@ class GeometryField(BaseSpatialField):
*,
extent=(-180.0, -90.0, 180.0, 90.0),
tolerance=0.05,
+ max_geom_collections=MAX_GEOM_COLLECTIONS,
**kwargs,
):
"""
@@ -278,6 +283,10 @@ class GeometryField(BaseSpatialField):
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
@@ -290,6 +299,10 @@ class GeometryField(BaseSpatialField):
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):
@@ -303,6 +316,8 @@ class GeometryField(BaseSpatialField):
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):
@@ -320,6 +335,7 @@ class GeometryField(BaseSpatialField):
"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 b415e147fc..e842ad0f22 100644
--- a/django/contrib/gis/db/models/proxy.py
+++ b/django/contrib/gis/db/models/proxy.py
@@ -43,7 +43,12 @@ class SpatialProxy(DeferredAttribute):
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 1fd31530c1..7835b22ced 100644
--- a/django/contrib/gis/forms/fields.py
+++ b/django/contrib/gis/forms/fields.py
@@ -1,6 +1,7 @@
from django import forms
from django.contrib.gis.gdal import GDALException
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 _
@@ -16,6 +17,7 @@ class GeometryField(forms.Field):
widget = OpenLayersWidget
geom_type = "GEOMETRY"
+ max_geom_collections = MAX_GEOM_COLLECTIONS
default_error_messages = {
"required": _("No geometry value provided."),
@@ -27,12 +29,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."""
@@ -47,7 +57,9 @@ class GeometryField(forms.Field):
value = None
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 49ca48794b..6c9fac774c 100644
--- a/django/contrib/gis/forms/widgets.py
+++ b/django/contrib/gis/forms/widgets.py
@@ -5,6 +5,7 @@ from django.conf import settings
from django.contrib.gis import gdal
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
from django.utils import translation
from django.utils.deprecation import RemovedInDjango51Warning
@@ -23,6 +24,7 @@ class BaseGeometryWidget(Widget):
map_width = 600 # RemovedInDjango51Warning
map_height = 400 # RemovedInDjango51Warning
display_raw = False
+ max_geom_collections = MAX_GEOM_COLLECTIONS
supports_3d = False
template_name = "" # set on subclasses
@@ -50,7 +52,7 @@ class BaseGeometryWidget(Widget):
def deserialize(self, value):
try:
- return GEOSGeometry(value)
+ return GEOSGeometry(value, max_geom_collections=self.max_geom_collections)
except (GEOSException, 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 8bbe2c264a..a505f03ee7 100644
--- a/django/contrib/gis/geos/geometry.py
+++ b/django/contrib/gis/geos/geometry.py
@@ -15,7 +15,14 @@ from django.contrib.gis.geos.error import GEOSException
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
@@ -113,8 +120,8 @@ class GEOSGeometryBase(GEOSBase):
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):
@@ -134,8 +141,8 @@ class GEOSGeometryBase(GEOSBase):
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):
@@ -720,7 +727,9 @@ class LinearGeometryMixin:
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:
@@ -734,6 +743,10 @@ class GEOSGeometry(GEOSGeometryBase, ListMixin):
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):
@@ -744,10 +757,10 @@ class GEOSGeometry(GEOSGeometryBase, ListMixin):
# 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)
@@ -760,7 +773,7 @@ class GEOSGeometry(GEOSGeometryBase, ListMixin):
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 efe9ec159f..c57145f85c 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.errcheck import (
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 @@ class IOBase(GEOSBase):
# ### 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
+ 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,18 +258,64 @@ 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:
- raise TypeError
+ 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 11e1bc77f4..9c8ed88676 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 173e51979c..8acae7cde8 100644
--- a/docs/ref/contrib/gis/geos.txt
+++ b/docs/ref/contrib/gis/geos.txt
@@ -207,10 +207,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
@@ -248,6 +250,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 981581cbf2..35fd0ea92b 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
+<django.contrib.gis.forms.Field.max_geom_collections>` 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/tests/gis_tests/geoapp/models.py b/tests/gis_tests/geoapp/models.py
index 2c13c827c6..58b92d550f 100644
--- a/tests/gis_tests/geoapp/models.py
+++ b/tests/gis_tests/geoapp/models.py
@@ -102,3 +102,7 @@ class ManyPointModel(NamedModel):
point1 = models.PointField()
point2 = models.PointField()
point3 = models.PointField(srid=3857)
+
+
+class GeometryCollectionModel(models.Model):
+ geom = models.GeometryCollectionField(max_geom_collections=5)
diff --git a/tests/gis_tests/geoapp/tests.py b/tests/gis_tests/geoapp/tests.py
index 6be13d4907..ae3470a899 100644
--- a/tests/gis_tests/geoapp/tests.py
+++ b/tests/gis_tests/geoapp/tests.py
@@ -1,6 +1,7 @@
import json
from io import StringIO
from pathlib import Path
+from unittest import mock
from django.contrib.gis import gdal
from django.contrib.gis.db.models import Extent, MakeLine, Union, functions
@@ -21,7 +22,7 @@ from django.core.files.temp import NamedTemporaryFile
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
@@ -30,6 +31,7 @@ from .models import (
City,
Country,
Feature,
+ GeometryCollectionModel,
MinusOneSRID,
MultiFields,
NonConcreteModel,
@@ -273,6 +275,52 @@ class GeoModelTest(TestCase):
self.assertEqual(feature.geom.srid, g.srid)
+class SaveLoadTests(TestCase):
+
+ 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"]
@@ -689,6 +737,15 @@ class GeoLookupTest(TestCase):
# 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 0000000000..1904436eaf
--- /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("<I", 1) + struct.pack("<dd", 0.0, 0.0)
+ collection = b"\x01" + struct.pack("<I", 7) + struct.pack("<I", 1)
+ multipolygon = b"\x01" + struct.pack("<I", 6) + struct.pack("<I", 1)
+ return (multipolygon + collection * depth + point).hex().upper()
+
+ msg = "WKB contains too many possible GeometryCollections."
+ # Depending on a GEOSException here would be unsafe, because the WKB
+ # grammar still allows recursion below the root.
+ with self.assertRaisesMessage(ValueError, msg):
+ GEOSGeometry(make_invalid_geom(6), max_geom_collections=5)
diff --git a/tests/gis_tests/rasterapp/test_rasterfield.py b/tests/gis_tests/rasterapp/test_rasterfield.py
index 37eec50027..289e71a4fa 100644
--- a/tests/gis_tests/rasterapp/test_rasterfield.py
+++ b/tests/gis_tests/rasterapp/test_rasterfield.py
@@ -1,4 +1,5 @@
import json
+from unittest import mock
from django.contrib.gis.db.models.fields import BaseSpatialField
from django.contrib.gis.db.models.functions import Distance
@@ -273,6 +274,21 @@ class RasterFieldTest(TransactionTestCase):
qs = RasterModel.objects.filter(Q(**combos[0]) & Q(**combos[1]))
self.assertIn(qs.count(), [0, 1])
+ def test_geometry_lookup_falls_back_to_default_max_geom_collections(self):
+ def make_geom(depth):
+ geom = "POINT(0 0)"
+ for _ in range(depth):
+ geom = f"GEOMETRYCOLLECTION({geom})"
+ return geom
+
+ msg = "WKT contains too many possible GeometryCollections."
+ # RasterField has no max_geom_collections, so the module default
+ # (patched here) applies. Under the limit parses; over it is rejected.
+ with mock.patch("django.contrib.gis.db.models.fields.MAX_GEOM_COLLECTIONS", 5):
+ self.assertEqual(RasterModel.objects.filter(rast=make_geom(4)).count(), 0)
+ with self.assertRaisesMessage(ValueError, msg):
+ RasterModel.objects.filter(rast=make_geom(6))
+
def test_dwithin_gis_lookup_output_with_rasters(self):
"""
Check the logical functionality of the dwithin lookup for different
diff --git a/tests/gis_tests/test_fields.py b/tests/gis_tests/test_fields.py
index 933514fee7..7ee17c0ebd 100644
--- a/tests/gis_tests/test_fields.py
+++ b/tests/gis_tests/test_fields.py
@@ -1,6 +1,8 @@
import copy
+from unittest import mock
from django.contrib.gis.db.models import GeometryField
+from django.contrib.gis.db.models.fields import BaseSpatialField
from django.contrib.gis.db.models.sql import AreaField, DistanceField
from django.test import SimpleTestCase
@@ -54,3 +56,34 @@ class GeometryFieldTests(SimpleTestCase):
"tolerance": 0.01,
},
)
+
+ def test_deconstruct_max_geom_collections(self):
+ # The default is omitted, a custom value is preserved.
+ field = GeometryField()
+ *_, kwargs = field.deconstruct()
+ self.assertNotIn("max_geom_collections", kwargs)
+
+ field = GeometryField(max_geom_collections=128)
+ *_, kwargs = field.deconstruct()
+ self.assertEqual(kwargs["max_geom_collections"], 128)
+
+ def test_formfield_forwards_max_geom_collections(self):
+ field = GeometryField(max_geom_collections=128)
+ self.assertEqual(field.formfield().max_geom_collections, 128)
+
+ def test_get_prep_value_without_max_geom_collections_uses_default(self):
+ # A spatial field that is not RASTER nor defines max_geom_collections
+ # still applies the default limit when preparing a lookup value.
+ class AttrlessSpatialField(BaseSpatialField):
+ geom_type = "GEOMETRY"
+
+ field = AttrlessSpatialField()
+ geom = "POINT(0 0)"
+ for _ in range(6):
+ geom = f"GEOMETRYCOLLECTION({geom})"
+ msg = "WKT contains too many possible GeometryCollections."
+ with (
+ mock.patch("django.contrib.gis.db.models.fields.MAX_GEOM_COLLECTIONS", 5),
+ self.assertRaisesMessage(ValueError, msg),
+ ):
+ field.get_prep_value(geom)
diff --git a/tests/gis_tests/test_geoforms.py b/tests/gis_tests/test_geoforms.py
index b980892790..6ee6e13f19 100644
--- a/tests/gis_tests/test_geoforms.py
+++ b/tests/gis_tests/test_geoforms.py
@@ -3,6 +3,7 @@ import re
from django.contrib.gis import forms
from django.contrib.gis.forms import BaseGeometryWidget, OpenLayersWidget
from django.contrib.gis.geos import GEOSGeometry
+from django.contrib.gis.geos.prototypes.io import MAX_GEOM_COLLECTIONS
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase, override_settings
from django.utils.deprecation import RemovedInDjango51Warning
@@ -43,6 +44,55 @@ class GeometryFieldTest(SimpleTestCase):
self.assertEqual(cleaned_geom.srid, 32140)
self.assertTrue(xform_geom.equals_exact(cleaned_geom, tol))
+ def test_max_geom_collections_default(self):
+ """The limit has a default and reaches the widget."""
+ fld = forms.GeometryField()
+ self.assertEqual(fld.max_geom_collections, MAX_GEOM_COLLECTIONS)
+ self.assertEqual(fld.widget.max_geom_collections, MAX_GEOM_COLLECTIONS)
+
+ def test_max_geom_collections_override(self):
+ """A per-field limit is enforced when cleaning nested collections."""
+ fld = forms.GeometryField(max_geom_collections=5)
+ # The override is propagated to the widget that does the parsing.
+ self.assertEqual(fld.widget.max_geom_collections, 5)
+
+ def make_geom(depth):
+ return "GEOMETRYCOLLECTION(" * depth + "POINT(0 0)" + ")" * depth
+
+ with self.assertRaisesMessage(ValidationError, "Invalid geometry value."):
+ fld.clean(make_geom(6))
+ self.assertIsNotNone(fld.clean(make_geom(5)))
+
+ def test_max_geom_collections_widget_without_deserialize(self):
+ # A widget without deserialize() (e.g. TextInput) uses to_python's
+ # fallback, which still applies the field's limit.
+ fld = forms.GeometryField(max_geom_collections=5, widget=forms.TextInput)
+
+ def make_geom(depth):
+ return "GEOMETRYCOLLECTION(" * depth + "POINT(0 0)" + ")" * depth
+
+ with self.assertRaisesMessage(ValidationError, "Invalid geometry value."):
+ fld.clean(make_geom(6))
+ self.assertIsNotNone(fld.clean(make_geom(5)))
+
+ def test_max_geom_collections_custom_widget_uses_default(self):
+ # A custom widget overriding deserialize() and ignoring the field's
+ # max_geom_collections still gets the default limit via GEOSGeometry.
+ class IgnoringWidget(BaseGeometryWidget):
+ def deserialize(self, value):
+ return GEOSGeometry(value) # no limit -> 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`)
--
2.44.4