diff --git a/meta-python/recipes-devtools/python/python3-django-5.0.14/CVE-2026-15830.patch b/meta-python/recipes-devtools/python/python3-django-5.0.14/CVE-2026-15830.patch
new file mode 100644
index 0000000000..7ef20fe1c9
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-django-5.0.14/CVE-2026-15830.patch
@@ -0,0 +1,1144 @@
+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
+
diff --git a/meta-python/recipes-devtools/python/python3-django_5.0.14.bb b/meta-python/recipes-devtools/python/python3-django_5.0.14.bb
index 8e98efcdac..96b2dec26f 100644
--- a/meta-python/recipes-devtools/python/python3-django_5.0.14.bb
+++ b/meta-python/recipes-devtools/python/python3-django_5.0.14.bb
@@ -11,6 +11,7 @@ SRC_URI += "file://CVE-2025-64460.patch \
             file://CVE-2025-59681.patch \
             file://CVE-2026-15307.patch \
             file://CVE-2026-15337.patch \
+            file://CVE-2026-15830.patch \
            "
 SRC_URI[sha256sum] = "29019a5763dbd48da1720d687c3522ef40d1c61be6fb2fad27ed79e9f655bc11"
 
