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("") 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 = {