diff --git a/django/contrib/admin/utils.py b/django/contrib/admin/utils.py
index 38aa98b6bdb6..768bb56add11 100644
--- a/django/contrib/admin/utils.py
+++ b/django/contrib/admin/utils.py
@@ -7,8 +7,8 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.templatetags.auth import render_password_as_hash
-from django.core.exceptions import FieldDoesNotExist
-from django.core.validators import EMPTY_VALUES
+from django.core.exceptions import FieldDoesNotExist, ValidationError
+from django.core.validators import EMPTY_VALUES, URLValidator
from django.db import models, router
from django.db.models.constants import LOOKUP_SEP
from django.db.models.deletion import Collector
@@ -464,6 +464,14 @@ def display_for_field(value, field, empty_value_display, avoid_link=False):
elif isinstance(field, models.FileField) and value and not avoid_link:
return format_html('{}', value.url, value)
elif isinstance(field, models.URLField) and value and not avoid_link:
+ # Only render a clickable link for URLs with a safe scheme, so that a
+ # potentially dangerous stored value is shown as plain text rather than
+ # an executable link. The check is deliberately independent of the
+ # field's own validators, which may permit such schemes.
+ try:
+ URLValidator()(value)
+ except ValidationError:
+ return display_for_value(value, empty_value_display)
return format_html('{}', value, value)
elif isinstance(field, models.JSONField) and value:
try:
diff --git a/django/contrib/gis/db/backends/mysql/features.py b/django/contrib/gis/db/backends/mysql/features.py
index 4e46ba40f3d0..d359bcc4e66e 100644
--- a/django/contrib/gis/db/backends/mysql/features.py
+++ b/django/contrib/gis/db/backends/mysql/features.py
@@ -20,3 +20,17 @@ def supports_geometry_field_unique_index(self):
# Not supported in MySQL since
# https://dev.mysql.com/worklog/task/?id=11808
return self.connection.mysql_is_mariadb
+
+ @cached_property
+ def django_test_skips(self):
+ skips = super().django_test_skips
+ if self.connection.mysql_is_mariadb:
+ skips.update(
+ {
+ "MariaDB doesn't support nested geometry collections.": {
+ "gis_tests.geoapp.tests.SaveLoadTests."
+ "test_geometrycollectionfield_default_max_ignored_on_read",
+ },
+ }
+ )
+ return skips
diff --git a/django/contrib/gis/db/backends/mysql/operations.py b/django/contrib/gis/db/backends/mysql/operations.py
index f48e05b67d7a..e5db38c79d2e 100644
--- a/django/contrib/gis/db/backends/mysql/operations.py
+++ b/django/contrib/gis/db/backends/mysql/operations.py
@@ -141,7 +141,9 @@ def get_geometry_converter(self, expression):
def converter(value, expression, connection):
if value is not None:
- geom = GEOSGeometryBase(read(memoryview(value)), geom_class)
+ geom = GEOSGeometryBase(
+ read(memoryview(value), max_geom_collections=None), geom_class
+ )
if srid:
geom.srid = srid
return geom
diff --git a/django/contrib/gis/db/backends/oracle/features.py b/django/contrib/gis/db/backends/oracle/features.py
index f346d935738c..16279b8a1ea8 100644
--- a/django/contrib/gis/db/backends/oracle/features.py
+++ b/django/contrib/gis/db/backends/oracle/features.py
@@ -23,6 +23,10 @@ def django_test_skips(self):
"gis_tests.gis_migrations.test_operations.OperationTests."
"test_add_check_constraint",
},
+ "Oracle doesn't support nested geometry collections.": {
+ "gis_tests.geoapp.tests.SaveLoadTests."
+ "test_geometrycollectionfield_default_max_ignored_on_read",
+ },
}
)
return skips
diff --git a/django/contrib/gis/db/backends/oracle/operations.py b/django/contrib/gis/db/backends/oracle/operations.py
index 918ab7ce6412..9fa1b8da994b 100644
--- a/django/contrib/gis/db/backends/oracle/operations.py
+++ b/django/contrib/gis/db/backends/oracle/operations.py
@@ -244,7 +244,10 @@ def get_geometry_converter(self, expression):
def converter(value, expression, connection):
if value is not None:
- geom = GEOSGeometryBase(read(memoryview(value.read())), geom_class)
+ geom = GEOSGeometryBase(
+ read(memoryview(value.read()), max_geom_collections=None),
+ geom_class,
+ )
if srid:
geom.srid = srid
return geom
diff --git a/django/contrib/gis/db/backends/postgis/operations.py b/django/contrib/gis/db/backends/postgis/operations.py
index 3c5ea7687ff9..dcbb5e18c831 100644
--- a/django/contrib/gis/db/backends/postgis/operations.py
+++ b/django/contrib/gis/db/backends/postgis/operations.py
@@ -422,9 +422,12 @@ def get_geometry_converter(self, expression):
geom_class = expression.output_field.geom_class
def converter(value, expression, connection):
- if isinstance(value, str): # Coming from hex strings.
- value = value.encode("ascii")
- return None if value is None else GEOSGeometryBase(read(value), geom_class)
+ if value is not None:
+ if isinstance(value, str): # Coming from hex strings.
+ value = value.encode("ascii")
+ return GEOSGeometryBase(
+ read(value, max_geom_collections=None), geom_class
+ )
return converter
diff --git a/django/contrib/gis/db/backends/spatialite/operations.py b/django/contrib/gis/db/backends/spatialite/operations.py
index 1cfd56964fd0..e649c25ffa96 100644
--- a/django/contrib/gis/db/backends/spatialite/operations.py
+++ b/django/contrib/gis/db/backends/spatialite/operations.py
@@ -230,6 +230,9 @@ def get_geometry_converter(self, expression):
read = wkb_r().read
def converter(value, expression, connection):
- return None if value is None else GEOSGeometryBase(read(value), geom_class)
+ if value is not None:
+ return GEOSGeometryBase(
+ read(value, max_geom_collections=None), geom_class
+ )
return converter
diff --git a/django/contrib/gis/db/models/fields.py b/django/contrib/gis/db/models/fields.py
index 14076d305b72..a001c9a72050 100644
--- a/django/contrib/gis/db/models/fields.py
+++ b/django/contrib/gis/db/models/fields.py
@@ -3,6 +3,9 @@
from django.contrib.gis import forms, gdal
from django.contrib.gis.db.models.proxy import SpatialProxy
from django.contrib.gis.gdal.error import GDALException
+from django.contrib.gis.gdal.raster.const import VSI_FILESYSTEM_PREFIX
+from django.contrib.gis.gdal.raster.source import DisallowedRasterLookup
+from django.contrib.gis.geometry import json_regex
from django.contrib.gis.geos import (
GeometryCollection,
GEOSException,
@@ -14,6 +17,7 @@
Point,
Polygon,
)
+from django.contrib.gis.geos.prototypes.io import MAX_GEOM_COLLECTIONS
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Field
from django.utils.translation import gettext_lazy as _
@@ -170,21 +174,19 @@ def get_db_prep_value(self, value, connection, *args, **kwargs):
def get_raster_prep_value(self, value, is_candidate):
"""
Return a GDALRaster if conversion is successful, otherwise return None.
+
+ Unless the user opts in by wrapping values in a GDALRaster, raise
+ DisallowedRasterLookup for values that fetch or write to disk.
"""
if isinstance(value, gdal.GDALRaster):
return value
- elif is_candidate:
+ gdal.GDALRaster.check_raster_lookup_value(value)
+ if is_candidate:
try:
return gdal.GDALRaster(value)
except GDALException:
pass
- elif isinstance(value, dict):
- try:
- return gdal.GDALRaster(value)
- except GDALException:
- raise ValueError(
- "Couldn't create spatial object from lookup value '%s'." % value
- )
+ return None
def get_prep_value(self, value):
obj = super().get_prep_value(value)
@@ -201,22 +203,39 @@ def get_prep_value(self, value):
obj, "__geo_interface__"
)
# Try to convert the input to raster.
- raster = self.get_raster_prep_value(obj, is_candidate)
-
+ raster = None
+ blocked_err = None
+ try:
+ raster = self.get_raster_prep_value(obj, is_candidate)
+ except DisallowedRasterLookup as err:
+ if isinstance(obj, dict):
+ raise err
+ # Don't immediately raise in case this is a valid GEOSGeometry.
+ blocked_err = err
if raster:
obj = raster
elif is_candidate:
+ 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
+ raise err
except (GEOSException, GDALException):
+ if isinstance(obj, str) and json_regex.match(obj):
+ raise blocked_err
raise ValueError(
"Couldn't create spatial object from lookup value '%s'." % obj
)
else:
- raise ValueError(
+ msg = (
"Cannot use object with type %s for a spatial lookup parameter."
% type(obj).__name__
)
+ raise blocked_err or ValueError(msg)
# Assigning the SRID value.
obj.srid = self.get_srid(obj)
@@ -244,6 +263,7 @@ def __init__(
*,
extent=(-180.0, -90.0, 180.0, 90.0),
tolerance=0.05,
+ max_geom_collections=MAX_GEOM_COLLECTIONS,
**kwargs,
):
"""
@@ -262,6 +282,10 @@ def __init__(
tolerance:
Define the tolerance, in meters, to use for the geometry field
entry in the `USER_SDO_GEOM_METADATA` table. Defaults to 0.05.
+
+ max_geom_collections:
+ The maximum number of geometry collections accepted before parsing is
+ refused, forwarded to the form field.
"""
# Setting the dimension of the geometry field.
self.dim = dim
@@ -274,6 +298,10 @@ def __init__(
self._extent = extent
self._tolerance = tolerance
+ # Limit on nested/total geometry collections, forwarded to the form
+ # field to guard against crashes in GEOS from deeply nested input.
+ self.max_geom_collections = max_geom_collections
+
super().__init__(verbose_name=verbose_name, **kwargs)
def deconstruct(self):
@@ -287,6 +315,8 @@ def deconstruct(self):
kwargs["extent"] = self._extent
if self._tolerance != 0.05:
kwargs["tolerance"] = self._tolerance
+ if self.max_geom_collections != MAX_GEOM_COLLECTIONS:
+ kwargs["max_geom_collections"] = self.max_geom_collections
return name, path, args, kwargs
def contribute_to_class(self, cls, name, **kwargs):
@@ -304,6 +334,7 @@ def formfield(self, **kwargs):
"form_class": self.form_class,
"geom_type": self.geom_type,
"srid": self.srid,
+ "max_geom_collections": self.max_geom_collections,
**kwargs,
}
if self.dim > 2 and not getattr(
diff --git a/django/contrib/gis/db/models/proxy.py b/django/contrib/gis/db/models/proxy.py
index 1b103aad6e4f..9af1505e8624 100644
--- a/django/contrib/gis/db/models/proxy.py
+++ b/django/contrib/gis/db/models/proxy.py
@@ -43,7 +43,12 @@ def __get__(self, instance, cls=None):
else:
# Otherwise, a geometry or raster object is built using the field's
# contents, and the model's corresponding attribute is set.
- geo_obj = self._load_func(geo_value)
+ try:
+ max_geoms = self.field.max_geom_collections
+ except AttributeError:
+ geo_obj = self._load_func(geo_value)
+ else:
+ geo_obj = self._load_func(geo_value, max_geom_collections=max_geoms)
setattr(instance, self.field.attname, geo_obj)
return geo_obj
diff --git a/django/contrib/gis/forms/fields.py b/django/contrib/gis/forms/fields.py
index 1d61ea0dddf6..dcc8bb219e25 100644
--- a/django/contrib/gis/forms/fields.py
+++ b/django/contrib/gis/forms/fields.py
@@ -1,5 +1,6 @@
from django import forms
from django.contrib.gis.geos import GEOSException, GEOSGeometry
+from django.contrib.gis.geos.prototypes.io import MAX_GEOM_COLLECTIONS
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
@@ -15,6 +16,7 @@ class GeometryField(forms.Field):
widget = OpenLayersWidget
geom_type = "GEOMETRY"
+ max_geom_collections = MAX_GEOM_COLLECTIONS
default_error_messages = {
"required": _("No geometry value provided."),
@@ -26,12 +28,20 @@ class GeometryField(forms.Field):
),
}
- def __init__(self, *, srid=None, geom_type=None, **kwargs):
+ def __init__(
+ self, *, srid=None, geom_type=None, max_geom_collections=None, **kwargs
+ ):
self.srid = srid
if geom_type is not None:
self.geom_type = geom_type
+ if max_geom_collections is not None:
+ self.max_geom_collections = max_geom_collections
super().__init__(**kwargs)
self.widget.attrs["geom_type"] = self.geom_type
+ # Propagate the limit to the (per-field) widget instance, which does
+ # the actual parsing. Custom widgets that override deserialize() and
+ # ignore this attribute still get the default limit via GEOSGeometry.
+ self.widget.max_geom_collections = self.max_geom_collections
def to_python(self, value):
"""Transform the value to a Geometry object."""
@@ -43,7 +53,9 @@ def to_python(self, value):
value = self.widget.deserialize(value)
else:
try:
- value = GEOSGeometry(value)
+ value = GEOSGeometry(
+ value, max_geom_collections=self.max_geom_collections
+ )
except (GEOSException, ValueError, TypeError):
value = None
if value is None:
diff --git a/django/contrib/gis/forms/widgets.py b/django/contrib/gis/forms/widgets.py
index 947f25a0bbdd..1a24975fa227 100644
--- a/django/contrib/gis/forms/widgets.py
+++ b/django/contrib/gis/forms/widgets.py
@@ -4,6 +4,7 @@
from django.contrib.gis.gdal import GDALException
from django.contrib.gis.geometry import json_regex
from django.contrib.gis.geos import GEOSException, GEOSGeometry
+from django.contrib.gis.geos.prototypes.io import MAX_GEOM_COLLECTIONS
from django.forms.widgets import Widget
logger = logging.getLogger("django.contrib.gis")
@@ -19,6 +20,7 @@ class BaseGeometryWidget(Widget):
geom_type = "GEOMETRY"
map_srid = 4326
display_raw = False
+ max_geom_collections = MAX_GEOM_COLLECTIONS
supports_3d = False
template_name = "" # set on subclasses
@@ -36,7 +38,7 @@ def serialize(self, value):
def deserialize(self, value):
try:
- return GEOSGeometry(value)
+ return GEOSGeometry(value, max_geom_collections=self.max_geom_collections)
except (GEOSException, GDALException, ValueError, TypeError) as err:
logger.error("Error creating geometry from value '%s' (%s)", value, err)
return None
diff --git a/django/contrib/gis/gdal/raster/source.py b/django/contrib/gis/gdal/raster/source.py
index 4f0eb875d35e..42ab1e3a7091 100644
--- a/django/contrib/gis/gdal/raster/source.py
+++ b/django/contrib/gis/gdal/raster/source.py
@@ -27,10 +27,19 @@
)
from django.contrib.gis.gdal.srs import SpatialReference, SRSException
from django.contrib.gis.geometry import json_regex
+from django.core.exceptions import SuspiciousOperation
from django.utils.encoding import force_bytes, force_str
from django.utils.functional import cached_property
+class DisallowedRasterLookup(SuspiciousOperation):
+ """
+ Types that force GDALRaster to open in write mode (dict) or values that
+ could be virtual filesystem paths (str) are not allowed in lookup contexts.
+ Instead, wrap values in GDALRaster explicitly.
+ """
+
+
class TransformPoint(list):
indices = {
"origin": (0, 3),
@@ -77,14 +86,10 @@ def __init__(self, ds_input, write=False):
self._write = 1 if write else 0
Driver.ensure_registered()
- # Preprocess json inputs. This converts json strings to dictionaries,
- # which are parsed below the same way as direct dictionary inputs.
- if isinstance(ds_input, str) and json_regex.match(ds_input):
- ds_input = json.loads(ds_input)
+ ds_input = self._preprocess_input(ds_input)
# If input is a valid file path, try setting file as source.
- if isinstance(ds_input, (str, Path)):
- ds_input = str(ds_input)
+ if isinstance(ds_input, str):
if not ds_input.startswith(VSI_FILESYSTEM_PREFIX) and not os.path.exists(
ds_input
):
@@ -226,6 +231,35 @@ def __repr__(self):
"""
return "" % hex(addressof(self._ptr))
+ @classmethod
+ def _preprocess_input(cls, ds_input):
+ """
+ Preprocess json and Path inputs. This converts json strings to
+ dictionaries, which are then parsed just like direct dictionary inputs.
+ This also stringifies Path objects.
+ """
+ if isinstance(ds_input, str) and json_regex.match(ds_input):
+ ds_input = json.loads(ds_input)
+ if isinstance(ds_input, Path):
+ ds_input = str(ds_input)
+ return ds_input
+
+ @classmethod
+ def check_raster_lookup_value(cls, ds_input):
+ """
+ Raise DisallowedRasterLookup for values inappropriate in lookups:
+ - No dicts, which GDALRaster(write=False) might still write to.
+ - No strings or Paths, which might fetch over the virtual filesystem.
+ """
+ normalized = cls._preprocess_input(ds_input)
+ if isinstance(normalized, (dict, str)):
+ msg = (
+ f"Cannot use object {normalized!r} for a spatial lookup "
+ "parameter. If this is a raster, wrap it with GDALRaster() "
+ "before using it in a lookup to enable writing or fetching."
+ )
+ raise DisallowedRasterLookup(msg)
+
def _flush(self):
"""
Flush all data from memory into the source file if it exists.
diff --git a/django/contrib/gis/geos/geometry.py b/django/contrib/gis/geos/geometry.py
index cbbecd47b131..06d5b4412565 100644
--- a/django/contrib/gis/geos/geometry.py
+++ b/django/contrib/gis/geos/geometry.py
@@ -15,7 +15,14 @@
from django.contrib.gis.geos.libgeos import GEOM_PTR, geos_version_tuple
from django.contrib.gis.geos.mutable_list import ListMixin
from django.contrib.gis.geos.prepared import PreparedGeometry
-from django.contrib.gis.geos.prototypes.io import ewkb_w, wkb_r, wkb_w, wkt_r, wkt_w
+from django.contrib.gis.geos.prototypes.io import (
+ MAX_GEOM_COLLECTIONS,
+ ewkb_w,
+ wkb_r,
+ wkb_w,
+ wkt_r,
+ wkt_w,
+)
from django.utils.deconstruct import deconstructible
from django.utils.encoding import force_bytes, force_str
@@ -114,8 +121,8 @@ def __setstate__(self, state):
self.srid = srid
@classmethod
- def _from_wkb(cls, wkb):
- return wkb_r().read(wkb)
+ def _from_wkb(cls, wkb, max_geom_collections=MAX_GEOM_COLLECTIONS):
+ return wkb_r().read(wkb, max_geom_collections)
@staticmethod
def from_ewkt(ewkt):
@@ -135,8 +142,8 @@ def from_ewkt(ewkt):
return GEOSGeometry(GEOSGeometry._from_wkt(wkt), srid=srid)
@staticmethod
- def _from_wkt(wkt):
- return wkt_r().read(wkt)
+ def _from_wkt(wkt, max_geom_collections=MAX_GEOM_COLLECTIONS):
+ return wkt_r().read(wkt, max_geom_collections)
@classmethod
def from_gml(cls, gml_string):
@@ -739,7 +746,9 @@ def closed(self):
class GEOSGeometry(GEOSGeometryBase, ListMixin):
"A class that, generally, encapsulates a GEOS geometry."
- def __init__(self, geo_input, srid=None):
+ def __init__(
+ self, geo_input, srid=None, *, max_geom_collections=MAX_GEOM_COLLECTIONS
+ ):
"""
The base constructor for GEOS geometry objects. It may take the
following inputs:
@@ -753,6 +762,10 @@ def __init__(self, geo_input, srid=None):
The `srid` keyword specifies the Source Reference Identifier (SRID)
number for this Geometry. If not provided, it defaults to None.
+
+ The `max_geom_collections` keyword limits how many nested (WKT) or
+ total (WKB) geometry collections the input may contain before parsing
+ is refused, guarding against segfaults from deeply nested input.
"""
input_srid = None
if isinstance(geo_input, bytes):
@@ -763,10 +776,10 @@ def __init__(self, geo_input, srid=None):
# Handle WKT input.
if wkt_m["srid"]:
input_srid = int(wkt_m["srid"])
- g = self._from_wkt(force_bytes(wkt_m["wkt"]))
+ g = self._from_wkt(force_bytes(wkt_m["wkt"]), max_geom_collections)
elif hex_regex.match(geo_input):
# Handle HEXEWKB input.
- g = wkb_r().read(force_bytes(geo_input))
+ g = wkb_r().read(force_bytes(geo_input), max_geom_collections)
elif json_regex.match(geo_input):
# Handle GeoJSON input.
ogr = gdal.OGRGeometry.from_json(geo_input)
@@ -779,7 +792,7 @@ def __init__(self, geo_input, srid=None):
g = geo_input
elif isinstance(geo_input, memoryview):
# When the input is a memoryview (WKB).
- g = wkb_r().read(geo_input)
+ g = wkb_r().read(geo_input, max_geom_collections)
elif isinstance(geo_input, GEOSGeometry):
g = capi.geom_clone(geo_input.ptr)
else:
diff --git a/django/contrib/gis/geos/prototypes/io.py b/django/contrib/gis/geos/prototypes/io.py
index e86d82ad0943..5e08d1450165 100644
--- a/django/contrib/gis/geos/prototypes/io.py
+++ b/django/contrib/gis/geos/prototypes/io.py
@@ -1,3 +1,4 @@
+import re
import threading
from ctypes import POINTER, Structure, byref, c_byte, c_char_p, c_int, c_size_t
@@ -15,6 +16,7 @@
from django.contrib.gis.geos.prototypes.geom import c_uchar_p, geos_char_p
from django.utils.encoding import force_bytes
from django.utils.functional import SimpleLazyObject
+from django.utils.regex_helper import _lazy_re_compile
# ### The WKB/WKT Reader/Writer structures and pointers ###
@@ -145,6 +147,58 @@ def __init__(self):
# ### Base WKB/WKT Reading and Writing objects ###
+# Sits just under PostGIS's effective ceiling: liblwgeom's LW_PARSER_MAX_DEPTH
+# is 200 and counts the leaf geometry, so PostGIS rejects at 199 nested
+# collections. 198 keeps Django's guard below that (and far below the GEOS
+# segfault threshold) so it rejects before any backend supporting nested
+# geometries does. (Oracle and MariaDB don't support nesting.)
+MAX_GEOM_COLLECTIONS = 198
+
+# GEOS accepts any amount of whitespace around the optional dimension marker,
+# so the separators must be \s*, not \s? or \s+. The root variants also allow
+# leading whitespace, which GEOS skips before the geometry type.
+_WKT_COLLECTION_START_RE = _lazy_re_compile(
+ r"\bGEOMETRYCOLLECTION(?:\s*(?:ZM|Z|M))?\s*\(",
+ re.IGNORECASE,
+)
+_WKT_COLLECTION_START_BYTES_RE = _lazy_re_compile(
+ rb"\bGEOMETRYCOLLECTION(?:\s*(?:ZM|Z|M))?\s*\(",
+ re.IGNORECASE,
+)
+_WKT_COLLECTION_ROOT_RE = _lazy_re_compile(
+ r"\s*\bGEOMETRYCOLLECTION(?:\s*(?:ZM|Z|M))?\s*\(",
+ re.IGNORECASE,
+)
+_WKT_COLLECTION_ROOT_BYTES_RE = _lazy_re_compile(
+ rb"\s*\bGEOMETRYCOLLECTION(?:\s*(?:ZM|Z|M))?\s*\(",
+ re.IGNORECASE,
+)
+
+
+def _build_collection_header_re():
+ """GEOS normalizes WKB types using: (type_code & 0xFFFF) % 1000
+
+ Therefore, every low 16-bit value congruent to 7 modulo 1000 is interpreted
+ as a GeometryCollection. Upper 16 bits may contain arbitrary EWKB flags.
+ """
+ low_types = range(7, 0x10000, 1000)
+ little_endian_types = b"|".join(
+ re.escape(type_code.to_bytes(2, "little")) for type_code in low_types
+ )
+ big_endian_types = b"|".join(
+ re.escape(type_code.to_bytes(2, "big")) for type_code in low_types
+ )
+ return _lazy_re_compile(
+ rb"(?=("
+ rb"\x01(?:" + little_endian_types + rb")[\x00-\xff]{2}"
+ rb"|"
+ rb"\x00[\x00-\xff]{2}(?:" + big_endian_types + rb")"
+ rb"))"
+ )
+
+
+_COLLECTION_HEADER_RE = _build_collection_header_re()
+
# Non-public WKB/WKT reader classes for internal use because
# their `read` methods return _pointers_ instead of GEOSGeometry
@@ -154,9 +208,48 @@ class _WKTReader(IOBase):
ptr_type = WKT_READ_PTR
destructor = wkt_reader_destroy
- def read(self, wkt):
+ def limit(self, wkt, max_geom_collections):
+ if max_geom_collections is None:
+ return
+ if isinstance(wkt, str):
+ pattern = _WKT_COLLECTION_START_RE
+ root_pattern = _WKT_COLLECTION_ROOT_RE
+ open_paren = "("
+ close_paren = ")"
+ else:
+ pattern = _WKT_COLLECTION_START_BYTES_RE
+ root_pattern = _WKT_COLLECTION_ROOT_BYTES_RE
+ open_paren = ord("(")
+ close_paren = ord(")")
+ if root_pattern.match(wkt) is None:
+ # Fast path: If the beginning does not match GEOMETRYCOLLECTION(,
+ # then GEOS rejects early (no need to limit):
+ # GEOS_ERROR: countered : 'GEOMETRYCOLLECTION'
+ return
+ collection_starts = {match.end() - 1 for match in pattern.finditer(wkt)}
+ # Nesting depth can't exceed the total number of collections, so if the
+ # total is already within the limit, there is nothing to walk.
+ if len(collection_starts) <= max_geom_collections:
+ return
+ collection_depth = 0
+ parentheses = []
+ for index, char in enumerate(wkt):
+ if char == open_paren:
+ is_collection = index in collection_starts
+ parentheses.append(is_collection)
+ if is_collection:
+ collection_depth += 1
+ elif char == close_paren and parentheses:
+ if parentheses.pop():
+ collection_depth -= 1
+ if collection_depth > max_geom_collections:
+ msg = "WKT contains too many possible GeometryCollections."
+ raise ValueError(msg)
+
+ def read(self, wkt, max_geom_collections=MAX_GEOM_COLLECTIONS):
if not isinstance(wkt, (bytes, str)):
raise TypeError(f"'wkt' must be bytes or str (got {wkt!r} instead).")
+ self.limit(wkt, max_geom_collections)
return wkt_reader_read(self.ptr, force_bytes(wkt))
@@ -165,21 +258,65 @@ class _WKBReader(IOBase):
ptr_type = WKB_READ_PTR
destructor = wkb_reader_destroy
- def read(self, wkb):
+ def limit_wkb(self, wkb, max_geom_collections):
+ if max_geom_collections is None:
+ return
+ for count, _ in enumerate(_COLLECTION_HEADER_RE.finditer(wkb), 1):
+ if count > max_geom_collections:
+ msg = "WKB contains too many possible GeometryCollections."
+ raise ValueError(msg)
+
+ def limit_hex(self, wkb, max_geom_collections):
+ if max_geom_collections is None:
+ return
+
+ def _byteswap_uint32(value):
+ return (
+ ((value & 0x000000FF) << 24)
+ | ((value & 0x0000FF00) << 8)
+ | ((value & 0x00FF0000) >> 8)
+ | ((value & 0xFF000000) >> 24)
+ )
+
+ count = 0
+ for index in range(0, len(wkb) - 9, 2):
+ byte_order = wkb[index : index + 2]
+ if byte_order not in (b"00", b"01"):
+ continue
+ try:
+ geometry_type = int(wkb[index + 2 : index + 10], 16)
+ except ValueError:
+ continue
+ geometry_type = _byteswap_uint32(geometry_type)
+ # Match GEOS WKBReader's geometry-type normalization.
+ if (geometry_type & 0xFFFF) % 1000 == 7: # GeometryCollection.
+ count += 1
+ if count > max_geom_collections:
+ msg = "WKB contains too many possible GeometryCollections."
+ raise ValueError(msg)
+
+ def read(self, wkb, max_geom_collections=MAX_GEOM_COLLECTIONS):
"Return a _pointer_ to C GEOS Geometry object from the given WKB."
+ limiter = self.limit_hex
+ reader = wkb_reader_read_hex
+
if isinstance(wkb, memoryview):
- wkb_s = bytes(wkb)
- return wkb_reader_read(self.ptr, wkb_s, len(wkb_s))
- elif isinstance(wkb, bytes):
- return wkb_reader_read_hex(self.ptr, wkb, len(wkb))
+ wkb = bytes(wkb)
+ limiter = self.limit_wkb
+ reader = wkb_reader_read
elif isinstance(wkb, str):
- wkb_s = wkb.encode()
- return wkb_reader_read_hex(self.ptr, wkb_s, len(wkb_s))
- else:
+ wkb = wkb.encode()
+ elif not isinstance(wkb, bytes):
raise TypeError(
f"'wkb' must be bytes, str or memoryview (got {wkb!r} instead)."
)
+ # Limit nested geometry collections. Should become unnecessary when
+ # GEOS 3.15.0 is the minimum supported version. See:
+ # https://github.com/libgeos/geos/commit/8b8b3da7a3d9fb8953ff60bc49aa0320d51ae45c
+ limiter(wkb, max_geom_collections)
+ return reader(self.ptr, wkb, len(wkb))
+
def default_trim_value():
"""
diff --git a/django/db/backends/base/features.py b/django/db/backends/base/features.py
index c9e78b574650..9f00dcfc3537 100644
--- a/django/db/backends/base/features.py
+++ b/django/db/backends/base/features.py
@@ -450,6 +450,7 @@ class BaseDatabaseFeatures:
]
supports_uuid4_function = False
+ supports_uuid4_function_in_default = False
supports_uuid7_function = False
supports_uuid7_function_shift = False
diff --git a/django/db/backends/mysql/features.py b/django/db/backends/mysql/features.py
index e7970cb063fa..24a8675c01cb 100644
--- a/django/db/backends/mysql/features.py
+++ b/django/db/backends/mysql/features.py
@@ -237,6 +237,10 @@ def supports_uuid4_function(self):
return self.connection.mysql_version >= (11, 7)
return False
+ supports_uuid4_function_in_default = property(
+ operator.attrgetter("supports_uuid4_function")
+ )
+
@cached_property
def supports_uuid7_function(self):
if self.connection.mysql_is_mariadb:
diff --git a/django/db/backends/oracle/features.py b/django/db/backends/oracle/features.py
index 81bb28ea2a85..3ca7894b2f63 100644
--- a/django/db/backends/oracle/features.py
+++ b/django/db/backends/oracle/features.py
@@ -235,6 +235,10 @@ def supports_tuple_lookups(self):
def supports_uuid4_function(self):
return self.connection.oracle_version >= (23, 9)
+ @cached_property
+ def supports_uuid4_function_in_default(self):
+ return self.connection.oracle_version >= (23, 26, 2)
+
@cached_property
def supports_stored_generated_columns(self):
return self.connection.oracle_version >= (23, 7)
diff --git a/django/db/backends/postgresql/features.py b/django/db/backends/postgresql/features.py
index b4a257547522..fa23aea34579 100644
--- a/django/db/backends/postgresql/features.py
+++ b/django/db/backends/postgresql/features.py
@@ -190,3 +190,6 @@ def is_postgresql_18(self):
supports_virtual_generated_columns = property(
operator.attrgetter("is_postgresql_18")
)
+ supports_uuid4_function_in_default = property(
+ operator.attrgetter("supports_uuid4_function")
+ )
diff --git a/django/db/backends/sqlite3/features.py b/django/db/backends/sqlite3/features.py
index ed24341b5e6c..e993666de5ba 100644
--- a/django/db/backends/sqlite3/features.py
+++ b/django/db/backends/sqlite3/features.py
@@ -172,3 +172,6 @@ def supports_json_field(self):
can_introspect_json_field = property(operator.attrgetter("supports_json_field"))
has_json_object_function = property(operator.attrgetter("supports_json_field"))
+ supports_uuid4_function_in_default = property(
+ operator.attrgetter("supports_uuid4_function")
+ )
diff --git a/django/test/signals.py b/django/test/signals.py
index f594ae434b2e..d24feeaffae3 100644
--- a/django/test/signals.py
+++ b/django/test/signals.py
@@ -153,7 +153,7 @@ def language_changed(*, setting, **kwargs):
from django.utils.translation import trans_real
trans_real._translations = {}
- trans_real.check_for_language.cache_clear()
+ trans_real.translation_catalog_exists.cache_clear()
@receiver(setting_changed)
diff --git a/django/utils/translation/trans_real.py b/django/utils/translation/trans_real.py
index 6fb6517a1017..1a5a08991171 100644
--- a/django/utils/translation/trans_real.py
+++ b/django/utils/translation/trans_real.py
@@ -31,9 +31,10 @@
# magic gettext number to separate context from message
CONTEXT_SEPARATOR = "\x04"
-# Maximum number of characters that will be parsed from the Accept-Language
-# header or cookie to prevent possible denial of service or memory exhaustion
-# attacks. About 10x longer than the longest value shown on MDN’s
+# Maximum length of a language code that will be processed, to prevent possible
+# denial of service or memory exhaustion attacks. Language codes are taken from
+# the Accept-Language header, the language cookie, the URL path prefix, or the
+# set_language() view. 500 is about 10x the longest value shown on MDN's
# Accept-Language page.
LANGUAGE_CODE_MAX_LENGTH = 500
@@ -65,7 +66,7 @@ def reset_cache(*, setting, **kwargs):
languages should no longer be accepted.
"""
if setting in ("LANGUAGES", "LANGUAGE_CODE"):
- check_for_language.cache_clear()
+ translation_catalog_exists.cache_clear()
get_languages.cache_clear()
get_supported_language_variant.cache_clear()
@@ -462,19 +463,29 @@ def all_locale_paths():
return [globalpath, *settings.LOCALE_PATHS, *app_paths]
-@functools.lru_cache(maxsize=1000)
def check_for_language(lang_code):
"""
Check whether there is a global language file for the given language
code. This is used to decide whether a user-provided language is
available.
- lru_cache should have a maxsize to prevent from memory exhaustion attacks,
- as the provided language codes are taken from the HTTP request. See also
+ Reject over-length codes before the cached lookup so that oversized,
+ attacker-controlled values are not retained as cache keys.
+ """
+ if lang_code is None or len(lang_code) > LANGUAGE_CODE_MAX_LENGTH:
+ return False
+ return translation_catalog_exists(lang_code)
+
+
+@functools.lru_cache(maxsize=1000)
+def translation_catalog_exists(lang_code):
+ """Return whether a translation catalog exists for the given language code.
+
+ lru_cache should have a maxsize to prevent memory exhaustion attacks. See:
.
"""
# First, a quick check to make sure lang_code is well-formed (#21458)
- if lang_code is None or not language_code_re.search(lang_code):
+ if not language_code_re.search(lang_code):
return False
return any(
gettext_module.find("django", path, [to_locale(lang_code)]) is not None
diff --git a/docs/ref/contrib/gis/db-api.txt b/docs/ref/contrib/gis/db-api.txt
index 43d237b64c4c..7b2ada03849b 100644
--- a/docs/ref/contrib/gis/db-api.txt
+++ b/docs/ref/contrib/gis/db-api.txt
@@ -147,11 +147,21 @@ GeoDjango are only available on spatial fields.
Filters on 'normal' fields (e.g. :class:`~django.db.models.CharField`)
may be chained with those on geographic fields. Geographic lookups accept
-geometry and raster input on both sides and input types can be mixed freely.
+geometry and raster input on both sides, and input types can be mixed freely in
+most cases. However, unlike assignments to model fields, with lookups,
+types such as ``str``, :class:`pathlib.Path`, and ``dict`` must be wrapped by
+:class:`~django.contrib.gis.gdal.GDALRaster` to signify that the potential for
+file writing or network fetching is acceptable. For the rationale, see
+:ref:`raster security considerations `.
The general structure of geographic lookups is described below. A complete
reference can be found in the :ref:`spatial lookup reference`.
+.. versionchanged:: 5.2.17
+
+ In earlier versions, spatial lookups accepted ``str`` and ``dict`` types
+ for new rasters, allowing file writes and network fetches.
+
Geometry Lookups
----------------
diff --git a/docs/ref/contrib/gis/forms-api.txt b/docs/ref/contrib/gis/forms-api.txt
index c65fd78692b3..8af76d5d1bc3 100644
--- a/docs/ref/contrib/gis/forms-api.txt
+++ b/docs/ref/contrib/gis/forms-api.txt
@@ -36,6 +36,30 @@ GeoDjango form fields take the following optional arguments.
be set up depending on the field class. It matches the OpenGIS standard
geometry name.
+``max_geom_collections``
+------------------------
+
+.. attribute:: Field.max_geom_collections
+
+ .. versionadded:: 5.2.17
+
+ The maximum number of geometry collections the field accepts before
+ refusing to parse the input and raising a
+ :exc:`~django.core.exceptions.ValidationError`. This guards against crashes
+ in the underlying GEOS library when parsing deeply nested
+ ``GEOMETRYCOLLECTION`` input. It defaults to ``198``.
+
+ The limit is applied differently depending on the input format: for
+ well-known text (WKT) it bounds the nesting *depth*, while for well-known
+ binary (WKB and hex-encoded WKB) it bounds the *total* number of geometry
+ collections (both breadth and depth). As a result, the same value may
+ accept a wide, shallow collection as WKT but reject it as WKB. For GeoJSON,
+ the limit is not applied at all, since GDAL parses that input type instead.
+
+ Increase this value (or set to ``None``) only if you must accept
+ legitimately deep geometries, since doing so reduces protection against
+ fatal errors.
+
Form field classes
==================
diff --git a/docs/ref/contrib/gis/gdal.txt b/docs/ref/contrib/gis/gdal.txt
index 83cd8ae586d1..33300924a7ea 100644
--- a/docs/ref/contrib/gis/gdal.txt
+++ b/docs/ref/contrib/gis/gdal.txt
@@ -2138,6 +2138,40 @@ previously configured for authentication and possibly other settings (see the
.. _`GDAL Virtual Filesystems documentation`: https://gdal.org/user/virtual_file_systems.html
+.. _raster-security:
+
+Security considerations
+~~~~~~~~~~~~~~~~~~~~~~~
+
+Since :class:`GDALRaster` always opens new rasters in write mode, it is
+essential to prevent instantiating one from untrusted input. Otherwise, an
+attacker might gain the ability to write a file or make a network request.
+
+To mitigate this, :ref:`spatial lookups ` prevent
+``str``, :class:`pathlib.Path`, and ``dict`` values from reaching
+:class:`GDALRaster` altogether. To use these types with lookups, wrap them
+explicitly with :class:`GDALRaster`, indicating that the value is trusted.
+Bytes are accepted without being wrapped in :class:`GDALRaster` because they
+are opened through GDAL's memory-based :ref:`virtual filesystem
+`.
+
+This protection applies only to spatial lookups. Assigning a ``dict`` value to
+a :class:`~django.contrib.gis.db.models.RasterField` will still open a new
+raster, and assigning a ``str`` or ``Path`` will still fetch and open the
+referenced raster.
+
+When validating geometry inputs, the
+:class:`~django.contrib.gis.forms.GeometryField` form field will reject raster
+values. When validating raster inputs, you should write custom validation.
+
+For defense-in-depth strategies for limiting the available raster drivers, see
+`GDAL security considerations `_.
+
+.. versionchanged:: 5.2.17
+
+ In earlier versions, spatial lookups accepted ``str`` and ``dict`` types
+ for new rasters, allowing file writes and network fetches.
+
Settings
========
diff --git a/docs/ref/contrib/gis/geos.txt b/docs/ref/contrib/gis/geos.txt
index 31ab8f10085b..502e59fb6a5f 100644
--- a/docs/ref/contrib/gis/geos.txt
+++ b/docs/ref/contrib/gis/geos.txt
@@ -208,10 +208,12 @@ Geometry Objects
``GEOSGeometry``
----------------
-.. class:: GEOSGeometry(geo_input, srid=None)
+.. class:: GEOSGeometry(geo_input, srid=None, *, max_geom_collections=198)
:param geo_input: Geometry input value (string or :class:`memoryview`)
:param srid: spatial reference identifier
+ :param max_geom_collections: maximum number of nested (WKT) or total (WKB)
+ geometry collections accepted before parsing is refused
:type srid: int
This is the base class for all GEOS geometry objects. It initializes on the
@@ -249,6 +251,10 @@ WKB / EWKB ``memoryview``
For the GeoJSON format, the SRID is set based on the ``crs`` member. If ``crs``
isn't provided, the SRID defaults to 4326.
+.. versionchanged:: 5.2.17
+
+ The ``max_geom_collections`` parameter was added.
+
.. classmethod:: GEOSGeometry.from_gml(gml_string)
Constructs a :class:`GEOSGeometry` from the given GML string.
diff --git a/docs/ref/contrib/gis/model-api.txt b/docs/ref/contrib/gis/model-api.txt
index 54c5c7843588..765735d172b2 100644
--- a/docs/ref/contrib/gis/model-api.txt
+++ b/docs/ref/contrib/gis/model-api.txt
@@ -223,6 +223,20 @@ details.
Geography support is limited to PostGIS and will force the SRID to be 4326.
+``max_geom_collections``
+------------------------
+
+.. attribute:: GeometryField.max_geom_collections
+
+.. versionadded:: 5.2.17
+
+This option is forwarded to the :attr:`form field
+` generated for this model
+field, bounding how many geometry collections may be contained in submitted
+WKB/WKT inputs before raising :exc:`ValueError`. Since spatial field
+assignments are lazy, it is also checked when values are accessed, e.g. when
+saving an instance, but not when read from a database. It defaults to ``198``.
+
.. _geography-type:
Geography Type
diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt
index 1ec0e17862f8..08a264c1e041 100644
--- a/docs/ref/utils.txt
+++ b/docs/ref/utils.txt
@@ -1140,6 +1140,9 @@ For a complete discussion on the usage of the following see the
code (e.g. 'fr', 'pt_BR'). This is used to decide whether a user-provided
language is available.
+ ``lang_code`` has a maximum accepted length of 500 characters. ``False``
+ is returned if it exceeds this limit, before any language-file lookup.
+
.. function:: get_language()
Returns the currently selected language code. Returns ``None`` if
diff --git a/docs/releases/5.2.17.txt b/docs/releases/5.2.17.txt
index 769513899ded..b80f5818a9b4 100644
--- a/docs/releases/5.2.17.txt
+++ b/docs/releases/5.2.17.txt
@@ -7,3 +7,90 @@ Django 5.2.17 release notes
Django 5.2.17 fixes one security issue with severity "high", two security
issues with severity "moderate", and one security issue with severity "low" in
5.2.16.
+
+CVE-2026-15307: Server-side file-write and request forgery via spatial lookups
+==============================================================================
+
+Spatial lookups allowed ``str`` and ``dict`` lookup values to be passed to
+:class:`~django.contrib.gis.gdal.GDALRaster` when they represented rasters.
+Depending on the raster driver, this could write a file to disk (in some cases
+enabling remote code execution) or issue a network request as the Django
+process user. Because the admin changelist permits filtering via
+:meth:`~django.contrib.admin.ModelAdmin.lookup_allowed`, the flaw was reachable
+by staff users with view permission on any registered model containing a
+spatial field.
+
+The following types are now disallowed by spatial lookups:
+
+- ``dict``
+- A ``str`` that is not a valid
+ :class:`~django.contrib.gis.geos.GEOSGeometry`, e.g. a serialized dictionary
+
+This is a backward incompatible change. As a reminder, all untrusted user input
+should be validated before use. For that reason, assignments to model fields
+are unaffected and still accept these input types.
+
+For guidance on how to keep using these types in spatial lookups, on validating
+untrusted input, and on further security considerations, see
+:ref:`raster security considerations `.
+
+This issue has severity "high" according to the :ref:`Django security policy
+`.
+
+CVE-2026-15337: Potential denial-of-service vulnerability in ``check_for_language()``
+=====================================================================================
+
+:func:`~django.utils.translation.check_for_language` was subject to a potential
+denial-of-service attack when checking many distinct, very long language codes.
+Each code was used as a key in an in-memory cache, consuming process memory.
+
+The ``language`` value reaches this function through the
+:func:`django.views.i18n.set_language` view (not active by default) from POST
+data. Since request data is limited by :setting:`DATA_UPLOAD_MAX_MEMORY_SIZE`
+and the cache is configured to store a maximum number of entries, the memory
+that could be consumed was bounded.
+
+To mitigate this vulnerability, language codes longer than 500 characters are
+now rejected before the cached lookup.
+
+This issue has severity "low" according to the :ref:`Django security policy
+`.
+
+CVE-2026-15830: Potential denial-of-service vulnerability via nested geometry collections
+=========================================================================================
+
+:class:`~django.contrib.gis.geos.GEOSGeometry` was subject to a potential
+denial-of-service attack when provided deeply nested ``GEOMETRYCOLLECTION``
+objects, leading to a segmentation fault in GEOS. A maximum depth of 198
+``GEOMETRYCOLLECTION``\s is now enforced for the well-known text (WKT) format,
+and a maximum number of 198 ``GEOMETRYCOLLECTION``\s in total (breadth and
+depth) is enforced for well-known binary (WKB).
+
+:ref:`Lookups against spatial fields ` and the
+:class:`~django.contrib.gis.forms.GeometryField` form field were also affected.
+
+The limit can be customized through the new ``max_geom_collections`` argument,
+available on :class:`~django.contrib.gis.geos.GEOSGeometry`, the
+:attr:`form field `,
+and the :attr:`model field
+`. The limit
+is not applied to GeoJSON inputs, as they were parsed by GDAL and are not
+affected.
+
+This issue has severity "moderate" according to the :ref:`Django security
+policy `.
+
+CVE-2026-15920: Potential cross-site scripting via ``URLField`` values in the admin
+===================================================================================
+
+The admin renders :class:`~django.db.models.URLField` values as clickable links
+on changelist views and read-only fields. The link was generated without
+validating the value as a safe URL, so a stored value using a potentially
+dangerous scheme was rendered as a link.
+
+``URLField`` values shown via ``display_for_field`` are now validated using
+:class:`~django.core.validators.URLValidator` before a link is rendered, and
+displayed as plain text if validation is failed.
+
+This issue has severity "moderate" according to the :ref:`Django security
+policy `.
diff --git a/docs/releases/6.0.8.txt b/docs/releases/6.0.8.txt
index a3c56ecb83b8..404f9a250400 100644
--- a/docs/releases/6.0.8.txt
+++ b/docs/releases/6.0.8.txt
@@ -8,6 +8,93 @@ Django 6.0.8 fixes one security issue with severity "high", two security issues
with severity "moderate", one security issue with severity "low", and several
bugs in 6.0.7.
+CVE-2026-15307: Server-side file-write and request forgery via spatial lookups
+==============================================================================
+
+Spatial lookups allowed ``str`` and ``dict`` lookup values to be passed to
+:class:`~django.contrib.gis.gdal.GDALRaster` when they represented rasters.
+Depending on the raster driver, this could write a file to disk (in some cases
+enabling remote code execution) or issue a network request as the Django
+process user. Because the admin changelist permits filtering via
+:meth:`~django.contrib.admin.ModelAdmin.lookup_allowed`, the flaw was reachable
+by staff users with view permission on any registered model containing a
+spatial field.
+
+The following types are now disallowed by spatial lookups:
+
+- ``dict``
+- A ``str`` that is not a valid
+ :class:`~django.contrib.gis.geos.GEOSGeometry`, e.g. a serialized dictionary
+
+This is a backward incompatible change. As a reminder, all untrusted user input
+should be validated before use. For that reason, assignments to model fields
+are unaffected and still accept these input types.
+
+For guidance on how to keep using these types in spatial lookups, on validating
+untrusted input, and on further security considerations, see
+:ref:`raster security considerations `.
+
+This issue has severity "high" according to the :ref:`Django security policy
+`.
+
+CVE-2026-15337: Potential denial-of-service vulnerability in ``check_for_language()``
+=====================================================================================
+
+:func:`~django.utils.translation.check_for_language` was subject to a potential
+denial-of-service attack when checking many distinct, very long language codes.
+Each code was used as a key in an in-memory cache, consuming process memory.
+
+The ``language`` value reaches this function through the
+:func:`django.views.i18n.set_language` view (not active by default) from POST
+data. Since request data is limited by :setting:`DATA_UPLOAD_MAX_MEMORY_SIZE`
+and the cache is configured to store a maximum number of entries, the memory
+that could be consumed was bounded.
+
+To mitigate this vulnerability, language codes longer than 500 characters are
+now rejected before the cached lookup.
+
+This issue has severity "low" according to the :ref:`Django security policy
+`.
+
+CVE-2026-15830: Potential denial-of-service vulnerability via nested geometry collections
+=========================================================================================
+
+:class:`~django.contrib.gis.geos.GEOSGeometry` was subject to a potential
+denial-of-service attack when provided deeply nested ``GEOMETRYCOLLECTION``
+objects, leading to a segmentation fault in GEOS. A maximum depth of 198
+``GEOMETRYCOLLECTION``\s is now enforced for the well-known text (WKT) format,
+and a maximum number of 198 ``GEOMETRYCOLLECTION``\s in total (breadth and
+depth) is enforced for well-known binary (WKB).
+
+:ref:`Lookups against spatial fields ` and the
+:class:`~django.contrib.gis.forms.GeometryField` form field were also affected.
+
+The limit can be customized through the new ``max_geom_collections`` argument,
+available on :class:`~django.contrib.gis.geos.GEOSGeometry`, the
+:attr:`form field `,
+and the :attr:`model field
+`. The limit
+is not applied to GeoJSON inputs, as they were parsed by GDAL and are not
+affected.
+
+This issue has severity "moderate" according to the :ref:`Django security
+policy `.
+
+CVE-2026-15920: Potential cross-site scripting via ``URLField`` values in the admin
+===================================================================================
+
+The admin renders :class:`~django.db.models.URLField` values as clickable links
+on changelist views and read-only fields. The link was generated without
+validating the value as a safe URL, so a stored value using a potentially
+dangerous scheme was rendered as a link.
+
+``URLField`` values shown via ``display_for_field`` are now validated using
+:class:`~django.core.validators.URLValidator` before a link is rendered, and
+displayed as plain text if validation is failed.
+
+This issue has severity "moderate" according to the :ref:`Django security
+policy `.
+
Bugfixes
========
diff --git a/docs/releases/security.txt b/docs/releases/security.txt
index 50a87f6fc84d..d0babde13383 100644
--- a/docs/releases/security.txt
+++ b/docs/releases/security.txt
@@ -36,6 +36,50 @@ Issues under Django's security process
All security issues have been handled under versions of Django's security
process. These are listed below.
+August 4, 2026 - :cve:`2026-15307`
+----------------------------------
+
+Server-side file-write and request forgery via spatial lookups.
+`Full description
+`__
+
+* Django 6.1 :commit:`(patch) <39b3e2d0c743a338def6c473086ebc06865e86b6>`
+* Django 6.0 :commit:`(patch) <208f80cb682868b584ed0a78f23e4ba6304212aa>`
+* Django 5.2 :commit:`(patch) <115ffd0463a765ab1cc93de18e94b5459b8a300e>`
+
+August 4, 2026 - :cve:`2026-15337`
+----------------------------------
+
+Potential denial-of-service vulnerability in ``check_for_language()``.
+`Full description
+`__
+
+* Django 6.1 :commit:`(patch) <5b3523d29be25948e1dd90b3863a002f00fc865f>`
+* Django 6.0 :commit:`(patch) <224dbc832586ad5cfb0237c2ff30d14baeaddc6f>`
+* Django 5.2 :commit:`(patch) `
+
+August 4, 2026 - :cve:`2026-15830`
+----------------------------------
+
+Potential denial-of-service vulnerability via nested geometry collections.
+`Full description
+`__
+
+* Django 6.1 :commit:`(patch) <9e4a3f186b6b07b483bfd9195ea06734663fcd06>`
+* Django 6.0 :commit:`(patch) <6af5da31775417c610dbf9c3f1b5b8333d42daf6>`
+* Django 5.2 :commit:`(patch) `
+
+August 4, 2026 - :cve:`2026-15920`
+----------------------------------
+
+Potential cross-site scripting via ``URLField`` values in the admin.
+`Full description
+`__
+
+* Django 6.1 :commit:`(patch) <5a260d309a4c8010c2ebda24eb758a5d95e2508a>`
+* Django 6.0 :commit:`(patch) <13debb622a32720bda1bccda7622fd14fbf3931b>`
+* Django 5.2 :commit:`(patch) `
+
July 7, 2026 - :cve:`2026-48588`
--------------------------------
diff --git a/tests/admin_utils/tests.py b/tests/admin_utils/tests.py
index a2426d9f04d4..1ec64d031c9b 100644
--- a/tests/admin_utils/tests.py
+++ b/tests/admin_utils/tests.py
@@ -240,6 +240,19 @@ def test_url_display_for_field(self):
expected = 'http://example.com'
self.assertHTMLEqual(display_value, expected)
+ def test_url_display_for_field_invalid_url(self):
+ # An invalid URL, such as one with an unsafe scheme, is rendered as
+ # plain text instead of a clickable link.
+ model_field = models.URLField()
+ for value in [
+ "javascript:alert(1)",
+ "data:text/html,",
+ ]:
+ with self.subTest(value=value):
+ display_value = display_for_field(value, model_field, self.empty_value)
+ self.assertNotIn("") * depth + point(endian=">"), "big-endian WKB", True),
+ (
+ b"".join(layer(endian="<" if i % 2 == 0 else ">") for i in range(depth))
+ + point(endian=">"),
+ "mixed-endian WKB",
+ True,
+ ),
+ ]
+ binary += [(wkb(c, ch, d, s), label, cg) for label, c, ch, d, s, cg in variants]
+
+ payloads = []
+ for data, label, check_geos in binary:
+ payloads += [
+ (data.hex().upper(), f"{label}, uppercase hex string", check_geos),
+ (data.hex().encode("ascii"), f"{label}, lower hex bytes", check_geos),
+ (memoryview(data), f"{label}, memoryview", check_geos),
+ ]
+ wkt = "GEOMETRYCOLLECTION(" * depth + "POINT(0 0)" + ")" * depth
+ payloads += [(wkt, "WKT", True), (wkt.encode("ascii"), "WKT bytes", True)]
+ return payloads
+
+ def test_geometry_collection_limit_exceeded(self):
+ msg = "contains too many possible GeometryCollections."
+ payloads = self._generate_geometry_collection_payloads(depth=6)
+ for payload, label, check_geos in payloads:
+ with self.subTest(payload=label):
+ with self.assertRaisesMessage(ValueError, msg):
+ GEOSGeometry(payload, max_geom_collections=5)
+ # Valid cases.
+ if check_geos:
+ GEOSGeometry(payload, max_geom_collections=6)
+ GEOSGeometry(payload, max_geom_collections=None)
+
+ def test_wkt_geometry_collection_flat(self):
+ def wkt_payload_no_nesting(num_points):
+ # Many parentheses, but only one collection level.
+ return (
+ "GEOMETRYCOLLECTION("
+ + ",".join("POINT(0 0)" for _ in range(num_points))
+ + ")"
+ )
+
+ GEOSGeometry(wkt_payload_no_nesting(num_points=5), max_geom_collections=1)
+
+ def test_wkt_mixed_case_and_inner_whitespace_is_limited(self):
+ two_collections = (
+ "GEOMETRYCOLLECTION ( "
+ "geometrycollection ( "
+ "POINT (0 0), POINT(1 1)"
+ ") )"
+ )
+ msg = "WKT contains too many possible GeometryCollections."
+ with self.assertRaisesMessage(ValueError, msg):
+ GEOSGeometry(two_collections, max_geom_collections=1)
+ GEOSGeometry(two_collections, max_geom_collections=2)
+
+ def test_from_ewkt_leading_whitespace_is_limited(self):
+ # from_ewkt() hands the part after the SRID to the low-level reader,
+ # so leading whitespace never passes through wkt_regex.
+ wkt = " " + "GEOMETRYCOLLECTION(" * 200 + "POINT(0 0)" + ")" * 200
+ msg = "WKT contains too many possible GeometryCollections."
+ for value in wkt, wkt.encode():
+ with self.subTest(value=value):
+ with self.assertRaisesMessage(ValueError, msg):
+ GEOSGeometry.from_ewkt(value)
+
+ def test_wkt_dimension_marker_whitespace_is_limited(self):
+ def two_collections(separator):
+ collection = f"GEOMETRYCOLLECTION{separator}ZM"
+ return f"{collection}({collection}(POINT ZM (0 0 0 0)))"
+
+ msg = "WKT contains too many possible GeometryCollections."
+ # GEOS accepts any amount of whitespace before the dimension marker.
+ for separator in "", " ", " ":
+ with self.subTest(separator=separator):
+ value = two_collections(separator)
+ with self.assertRaisesMessage(ValueError, msg):
+ GEOSGeometry(value, max_geom_collections=1)
+ GEOSGeometry(value, max_geom_collections=2)
+
+ def test_wkt_reader_whitespace_is_limited(self):
+ # WKTReader.read() takes str and bytes directly, so the whitespace
+ # GEOS tolerates but wkt_regex rejects reaches the limiter.
+ reader = WKTReader()
+ msg = "WKT contains too many possible GeometryCollections."
+ for prefix in "", " ", "\t\n ":
+ for separator in "", " ", " ", "\t", "\n", " \t\n ":
+ collection = f"GEOMETRYCOLLECTION{separator}ZM"
+ point = "POINT ZM (0 0 0 0)"
+ depth = MAX_GEOM_COLLECTIONS + 1
+ over = prefix + f"{collection}(" * depth + point + ")" * depth
+ with self.subTest(prefix=prefix, separator=separator):
+ for value in over, over.encode():
+ with self.assertRaisesMessage(ValueError, msg):
+ reader.read(value)
+ under = f"{prefix}{collection}({point})"
+ self.assertEqual(reader.read(under).geom_type, "GeometryCollection")
+
+ def test_non_collection_wkt_root_fast_path(self):
+ def make_geom(depth):
+ return "GEOMETRYCOLLECTION(" * depth + "POINT(0 0)" + ")" * depth
+
+ invalid_wkt = "POLYGON(" + make_geom(6) + ")"
+ # Instead of raising a ValueError, a fast path skips the limit and
+ # depends on GEOS to reject collections found anywhere but the root.
+ with self.assertRaises(GEOSException):
+ GEOSGeometry(invalid_wkt, max_geom_collections=5)
+
+ def test_malformed_multi_wkb_child_is_limited(self):
+ def make_invalid_geom(depth):
+ point = b"\x01" + struct.pack(" default applies
+
+ fld = forms.GeometryField(max_geom_collections=5, widget=IgnoringWidget)
+
+ def make_geom(depth):
+ return "GEOMETRYCOLLECTION(" * depth + "POINT(0 0)" + ")" * depth
+
+ # The field's low limit (5) is ignored by the widget...
+ self.assertIsNotNone(fld.clean(make_geom(6)))
+ # ...but the default (198) still guards against deeper input.
+ with self.assertRaises(ValueError):
+ fld.clean(make_geom(MAX_GEOM_COLLECTIONS + 1))
+
def test_null(self):
"Testing GeometryField's handling of null (None) geometries."
# Form fields, by default, are required (`required=True`)
@@ -84,6 +136,19 @@ def test_geom_type(self):
with self.assertRaises(ValidationError):
pnt_fld.clean("LINESTRING(0 0, 1 1)")
+ def test_raster_types(self):
+ fld = forms.GeometryField()
+ for value in (
+ JSON_RASTER,
+ str(JSON_RASTER),
+ "/vsicurl/http://example.com/raster.tif",
+ ):
+ with (
+ self.subTest(value=value),
+ self.assertRaisesMessage(ValidationError, "Invalid geometry value."),
+ ):
+ fld.clean(value)
+
def test_to_python(self):
"""
to_python() either returns a correct GEOSGeometry object or
diff --git a/tests/i18n/tests.py b/tests/i18n/tests.py
index 91e7c95f67a0..d3db80efc6f5 100644
--- a/tests/i18n/tests.py
+++ b/tests/i18n/tests.py
@@ -59,7 +59,10 @@
translation_file_changed,
watch_for_translation_changes,
)
-from django.utils.translation.trans_real import LANGUAGE_CODE_MAX_LENGTH
+from django.utils.translation.trans_real import (
+ LANGUAGE_CODE_MAX_LENGTH,
+ translation_catalog_exists,
+)
from .forms import CompanyForm, I18nForm, SelectDateForm
from .models import Company, TestModel
@@ -2081,6 +2084,25 @@ def test_check_for_language(self):
self.assertFalse(check_for_language("tr-TR.UTF8"))
self.assertFalse(check_for_language("de-DE.utf-8"))
+ def test_check_for_language_lang_code_max_length(self):
+ self.addCleanup(translation_catalog_exists.cache_clear)
+
+ # Overly long codes are rejected before the cached lookup, so they are
+ # not retained as cache keys, potentially consuming too much memory.
+ # Codes at the maximum length can reach the cached lookup.
+ for length, cache_size in [
+ (LANGUAGE_CODE_MAX_LENGTH - 1, 1),
+ (LANGUAGE_CODE_MAX_LENGTH, 1),
+ (LANGUAGE_CODE_MAX_LENGTH + 1, 0),
+ ]:
+ translation_catalog_exists.cache_clear()
+ with self.subTest(length=length):
+ self.assertIs(check_for_language("a" * length), False)
+ self.assertEqual(
+ translation_catalog_exists.cache_info().currsize,
+ cache_size,
+ )
+
def test_check_for_language_null(self):
self.assertIs(trans_null.check_for_language("en"), True)
diff --git a/tests/inline_formsets/models.py b/tests/inline_formsets/models.py
index ccada27e4c88..af6680cec22f 100644
--- a/tests/inline_formsets/models.py
+++ b/tests/inline_formsets/models.py
@@ -27,7 +27,7 @@ class ParentUUIDPk(models.Model):
class Meta:
required_db_features = {
- "supports_uuid4_function",
+ "supports_uuid4_function_in_default",
"supports_expression_defaults",
}
@@ -38,7 +38,7 @@ class ChildUUIDPk(models.Model):
class Meta:
required_db_features = {
- "supports_uuid4_function",
+ "supports_uuid4_function_in_default",
"supports_expression_defaults",
}
diff --git a/tests/inline_formsets/tests.py b/tests/inline_formsets/tests.py
index 78456ec51487..3ef1e6e04d25 100644
--- a/tests/inline_formsets/tests.py
+++ b/tests/inline_formsets/tests.py
@@ -113,7 +113,9 @@ def test_save_new(self):
obj.save()
self.assertEqual(school.child_set.count(), 1)
- @skipUnlessDBFeature("supports_uuid4_function", "supports_expression_defaults")
+ @skipUnlessDBFeature(
+ "supports_uuid4_function_in_default", "supports_expression_defaults"
+ )
def test_add_form_uuid_pk(self):
ChildFormSet = inlineformset_factory(ParentUUIDPk, ChildUUIDPk, fields=["name"])
data = {