Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-Django4 for openSUSE:Factory checked in at 2026-08-06 16:25:20 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-Django4 (Old) and /work/SRC/openSUSE:Factory/.python-Django4.new.16738 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-Django4" Thu Aug 6 16:25:20 2026 rev:8 rq:1369756 version:4.2.30 Changes: -------- --- /work/SRC/openSUSE:Factory/python-Django4/python-Django4.changes 2026-07-09 22:21:36.046666006 +0200 +++ /work/SRC/openSUSE:Factory/.python-Django4.new.16738/python-Django4.changes 2026-08-06 16:27:30.747562078 +0200 @@ -1,0 +2,17 @@ +Wed Aug 5 12:50:13 UTC 2026 - Markéta Machová <[email protected]> + +- Add security patches: + * CVE-2026-15307: server-side file-write and request forgery via + spatial lookups (bsc#1272997) + * CVE-2026-15307.patch + * CVE-2026-15337: potential denial-of-service vulnerability in + `check_for_language()` (bsc#1272998) + * CVE-2026-15337.patch + * CVE-2026-15830: potential denial-of-service vulnerability via + nested geometry collections (bsc#1272999) + * CVE-2026-15830.patch + * CVE-2026-15920: potential cross-site scripting via `URLField` + values in the admin (bsc#1273000) + * CVE-2026-15920.patch + +------------------------------------------------------------------- New: ---- CVE-2026-15307.patch CVE-2026-15337.patch CVE-2026-15830.patch CVE-2026-15920.patch ----------(New B)---------- New: spatial lookups (bsc#1272997) * CVE-2026-15307.patch * CVE-2026-15337: potential denial-of-service vulnerability in New: `check_for_language()` (bsc#1272998) * CVE-2026-15337.patch * CVE-2026-15830: potential denial-of-service vulnerability via New: nested geometry collections (bsc#1272999) * CVE-2026-15830.patch * CVE-2026-15920: potential cross-site scripting via `URLField` New: values in the admin (bsc#1273000) * CVE-2026-15920.patch ----------(New E)---------- ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-Django4.spec ++++++ --- /var/tmp/diff_new_pack.2fxrUR/_old 2026-08-06 16:27:31.927602803 +0200 +++ /var/tmp/diff_new_pack.2fxrUR/_new 2026-08-06 16:27:31.931602942 +0200 @@ -79,6 +79,14 @@ Patch17: CVE-2026-48588.patch # PATCH-FIX-UPSTREAM CVE-2026-48588.patch bsc#1271030 Patch18: CVE-2026-53877.patch +# PATCH-FIX-UPSTREAM CVE-2026-15307.patch bsc#1272997 +Patch19: CVE-2026-15307.patch +# PATCH-FIX-UPSTREAM CVE-2026-15337.patch bsc#1272998 +Patch20: CVE-2026-15337.patch +# PATCH-FIX-UPSTREAM CVE-2026-15830.patch bsc#1272999 +Patch21: CVE-2026-15830.patch +# PATCH-FIX-UPSTREAM CVE-2026-15920.patch bsc#1273000 +Patch22: CVE-2026-15920.patch BuildRequires: %{python_module Jinja2 >= 2.9.2} BuildRequires: %{python_module Pillow >= 6.2.0} BuildRequires: %{python_module PyYAML} ++++++ CVE-2026-15307.patch ++++++ >From b51d43c7d7d8106b100016c311250ecb8a0b9bda Mon Sep 17 00:00:00 2001 From: Jacob Walls <[email protected]> Date: Thu, 9 Jul 2026 11:07:28 -0400 Subject: [PATCH 1/4] [5.2.x] Fixed CVE-2026-15307 -- Blocked raster strings and dicts in spatial lookups. Spatial lookups optimistically parse values as rasters before retrying as geometries. If a malicious value reached the GDALRaster constructor, depending on the raster driver, it might write to disk or fetch from the network regardless of the constructor's `write=False` default argument. Although this works as designed for model field assignment, this is potentially unexpected for querying, for example, in the admin's changelist view, which allows staff users to execute arbitrary lookups on models registered with the admin. Network rasters didn't even work in lookup contexts before, providing further evidence that this use case was unintentional. (The failure point was after the fetching, however.) Now, strings and dicts representing rasters are rejected by spatial lookups. To opt in to using them, wrap them in a `GDALRaster` first. Although it would simplify the implementation to try geometries before rasters (instead of stashing a raster exception and raising it later), we maintain the current order, which has been stable for a decade. Thanks Bence Nagy, localhost-detect, and kimchunbok_ for providing information useful in evaluating this report. Thanks Simon Charette, Natalia Bidart, and Sarah Boyce for reviews. --- django/contrib/gis/db/models/fields.py | 37 +++++--- django/contrib/gis/gdal/raster/source.py | 46 ++++++++-- docs/ref/contrib/gis/db-api.txt | 12 ++- docs/ref/contrib/gis/gdal.txt | 34 +++++++ docs/releases/5.2.17.txt | 29 ++++++ tests/gis_tests/geoadmin/tests.py | 25 ++++- tests/gis_tests/geoapp/tests.py | 91 +++++++++++++++++++ tests/gis_tests/rasterapp/test_rasterfield.py | 25 ++--- tests/gis_tests/test_geoforms.py | 12 +++ 9 files changed, 272 insertions(+), 39 deletions(-) Index: Django-4.2.11/django/contrib/gis/db/models/fields.py =================================================================== --- Django-4.2.11.orig/django/contrib/gis/db/models/fields.py +++ Django-4.2.11/django/contrib/gis/db/models/fields.py @@ -3,6 +3,9 @@ from collections import defaultdict, nam 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, @@ -172,21 +175,19 @@ class BaseSpatialField(Field): 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) @@ -202,22 +203,36 @@ class BaseSpatialField(Field): obj, "__geo_interface__" ) # Try to convert the input to raster. - raster = self.get_raster_prep_value(obj, is_candidate) - + raster = None + blocked_err = None + try: + raster = self.get_raster_prep_value(obj, is_candidate) + except DisallowedRasterLookup as err: + if isinstance(obj, dict): + raise err + # Don't immediately raise in case this is a valid GEOSGeometry. + blocked_err = err if raster: obj = raster elif is_candidate: try: obj = GEOSGeometry(obj) + except (TypeError, ValueError) as err: + if isinstance(obj, str) and obj.startswith(VSI_FILESYSTEM_PREFIX): + raise blocked_err + raise err except (GEOSException, GDALException): + if isinstance(obj, str) and json_regex.match(obj): + raise blocked_err raise ValueError( "Couldn't create spatial object from lookup value '%s'." % obj ) else: - raise ValueError( + msg = ( "Cannot use object with type %s for a spatial lookup parameter." % type(obj).__name__ ) + raise blocked_err or ValueError(msg) # Assigning the SRID value. obj.srid = self.get_srid(obj) Index: Django-4.2.11/django/contrib/gis/gdal/raster/source.py =================================================================== --- Django-4.2.11.orig/django/contrib/gis/gdal/raster/source.py +++ Django-4.2.11/django/contrib/gis/gdal/raster/source.py @@ -27,10 +27,19 @@ from django.contrib.gis.gdal.raster.cons ) 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 @@ class GDALRaster(GDALRasterBase): 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 ): @@ -225,6 +230,35 @@ class GDALRaster(GDALRasterBase): """ return "<Raster object at %s>" % 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. Index: Django-4.2.11/docs/ref/contrib/gis/db-api.txt =================================================================== --- Django-4.2.11.orig/docs/ref/contrib/gis/db-api.txt +++ Django-4.2.11/docs/ref/contrib/gis/db-api.txt @@ -146,11 +146,21 @@ GeoDjango are only available on spatial 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 <raster-security>`. The general structure of geographic lookups is described below. A complete reference can be found in the :ref:`spatial lookup reference<spatial-lookups>`. +.. 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 ---------------- Index: Django-4.2.11/docs/ref/contrib/gis/gdal.txt =================================================================== --- Django-4.2.11.orig/docs/ref/contrib/gis/gdal.txt +++ Django-4.2.11/docs/ref/contrib/gis/gdal.txt @@ -2002,6 +2002,40 @@ previously configured for authentication .. _`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 <spatial-lookups-intro>` 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 +<gdal-raster-vsimem>`. + +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 <https://gdal.org/user/security.html>`_. + +.. versionchanged:: 5.2.17 + + In earlier versions, spatial lookups accepted ``str`` and ``dict`` types + for new rasters, allowing file writes and network fetches. + Settings ======== Index: Django-4.2.11/tests/gis_tests/geoadmin/tests.py =================================================================== --- Django-4.2.11.orig/tests/gis_tests/geoadmin/tests.py +++ Django-4.2.11/tests/gis_tests/geoadmin/tests.py @@ -1,13 +1,26 @@ +from django.contrib.auth.models import Permission, User +from django.contrib.contenttypes.models import ContentType from django.contrib.gis.geos import Point -from django.test import SimpleTestCase, override_settings +from django.core.exceptions import SuspiciousOperation +from django.test import RequestFactory, TestCase, override_settings from .models import City, site, site_gis, site_gis_custom @override_settings(ROOT_URLCONF="django.contrib.gis.tests.geoadmin.urls") -class GeoAdminTest(SimpleTestCase): +class GeoAdminTest(TestCase): admin_site = site # ModelAdmin + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user("test", password="password", is_staff=True) + cls.user.user_permissions.add( + Permission.objects.get( + codename="view_city", + content_type=ContentType.objects.get_for_model(City), + ) + ) + def test_widget_empty_string(self): geoadmin = self.admin_site._registry[City] form = geoadmin.get_changelist_form(None)({"point": ""}) @@ -54,6 +67,14 @@ class GeoAdminTest(SimpleTestCase): self.assertIs(has_changed(initial, data_almost_same), False) self.assertIs(has_changed(initial, data_changed), True) + def test_raster_lookup_not_allowed(self): + geoadmin = self.admin_site.get_model_admin(City) + request = RequestFactory().get("/city/", data={"point": "/vsicurl/someurl"}) + request.user = self.user + msg = "Cannot use object '/vsicurl/someurl' for a spatial lookup parameter." + with self.assertRaisesMessage(SuspiciousOperation, msg): + geoadmin.get_changelist_instance(request) + class GISAdminTests(GeoAdminTest): admin_site = site_gis # GISModelAdmin Index: Django-4.2.11/tests/gis_tests/geoapp/tests.py =================================================================== --- Django-4.2.11.orig/tests/gis_tests/geoapp/tests.py +++ Django-4.2.11/tests/gis_tests/geoapp/tests.py @@ -1,8 +1,11 @@ +import json import tempfile from io import StringIO +from pathlib import Path from django.contrib.gis import gdal from django.contrib.gis.db.models import Extent, MakeLine, Union, functions +from django.contrib.gis.gdal.raster.source import DisallowedRasterLookup from django.contrib.gis.geos import ( GeometryCollection, GEOSGeometry, @@ -21,6 +24,7 @@ from django.db.models import F, OuterRef from django.test import TestCase, skipUnlessDBFeature from django.test.utils import CaptureQueriesContext +from ..data.rasters.textrasters import JSON_RASTER from ..utils import skipUnlessGISLookup from .models import ( City, @@ -591,6 +595,93 @@ class GeoLookupTest(TestCase): ) self.assertEqual(qs.get(), multifields) + def test_lookup_rejects_writing_or_fetching_rasters(self): + """ + GDALRaster enables write mode in the following cases even when the + value of the `write` parameter is False (default): + - dicts + - strings matching a json regex + - bytes + + Since this could be unexpected in a lookup context, disallow dicts + and strings: instead, explicitly wrap with GDALRaster() to signal that + a write or fetch is expected. Bytes only write to the in-memory virtual + filesystem, so allow them. + + Disallowing strings also disallows paths to local or network rasters, + but those didn't work in the lookup context anyway, since they were + never opened for writing, and lookups failed on setting the SRID with: + + GDALException: Raster needs to be opened in write mode to change values + + Still, a network fetch might have occurred before that failure point, + so disallow strings altogether. + """ + # Create a vsi-based raster from scratch. + vsimem_path = "/vsimem/raster.tif" + # Keep a reference to this raster while it is being re-parsed below. + # Otherwise, GDALRaster.__del__() will delete the in-memory raster. + _rast = gdal.GDALRaster( # NOQA: F841 + { + "name": vsimem_path, + "driver": "tif", + "width": 4, + "height": 4, + "srid": 4326, + "bands": [ + { + "data": range(16), + } + ], + } + ) + existing_path = Path(__file__).parent.parent / "data" / "rasters" / "raster.tif" + disallowed_cases = [ + JSON_RASTER, + json.loads(JSON_RASTER), + "/vsicurl/someurl", + "/vsicurl_streaming/someurl", + "/vsis3/someurl", + vsimem_path, + existing_path, + ] + for obj in disallowed_cases: + try: + msg_obj = json.loads(obj) + except Exception: + if isinstance(obj, Path): + msg_obj = str(obj) + else: + msg_obj = obj + msg = ( + f"Cannot use object {msg_obj!r} for a spatial lookup parameter. " + "If this is a raster, wrap it with GDALRaster() before using " + "it in a lookup to enable writing or fetching." + ) + with ( + self.subTest(obj=obj), + self.assertRaisesMessage(DisallowedRasterLookup, msg), + ): + City.objects.filter(point__contained=obj) + + # Strings having nothing to do with rasters raise a more generic error. + for obj in str(existing_path), "invalid": + msg = "String input unrecognized as WKT EWKT, and HEXEWKB." + with self.subTest(obj=obj), self.assertRaisesMessage(ValueError, msg): + City.objects.filter(point__contained=obj) + + def test_lookup_allows_writing_raster_from_bytes(self): + raster_path = Path(__file__).parent.parent / "data" / "rasters" / "raster.tif" + with open(raster_path, "rb") as raster_file: + raster_bytes = raster_file.read() + # Just get SQL to avoid gating on connection.supports_raster. + City.objects.filter(point__contained=raster_bytes).query + + def test_lookup_allows_geos_geometry_string(self): + geojson = json.dumps({"type": "Point", "coordinates": [2, 49]}) + # Just get SQL to avoid gating on connection.supports_raster. + City.objects.filter(point__contained=geojson).query + class GeoQuerySetTest(TestCase): # TODO: GeoQuerySet is removed, organize these test better. Index: Django-4.2.11/tests/gis_tests/rasterapp/test_rasterfield.py =================================================================== --- Django-4.2.11.orig/tests/gis_tests/rasterapp/test_rasterfield.py +++ Django-4.2.11/tests/gis_tests/rasterapp/test_rasterfield.py @@ -211,7 +211,7 @@ class RasterFieldTest(TransactionTestCas (stx_pnt, 0, 500), (stx_pnt, D(km=1000)), (rast, 500), - (json.loads(JSON_RASTER), 500), + (GDALRaster(json.loads(JSON_RASTER)), 500), ] elif name == "relate": # Set lookup values for the relate lookup. @@ -222,7 +222,7 @@ class RasterFieldTest(TransactionTestCas (stx_pnt, 0, "T*T***FF*"), (stx_pnt, "T*T***FF*"), (rast, "T*T***FF*"), - (json.loads(JSON_RASTER), "T*T***FF*"), + (GDALRaster(json.loads(JSON_RASTER)), "T*T***FF*"), ] elif name == "isvalid": # The isvalid lookup doesn't make sense for rasters. @@ -236,7 +236,7 @@ class RasterFieldTest(TransactionTestCas (stx_pnt, 0), stx_pnt, rast, - json.loads(JSON_RASTER), + GDALRaster(json.loads(JSON_RASTER)), ] else: # Override band lookup for these, as it's not supported. @@ -249,7 +249,7 @@ class RasterFieldTest(TransactionTestCas stx_pnt, stx_pnt, rast, - json.loads(JSON_RASTER), + GDALRaster(json.loads(JSON_RASTER)), ] # Create query filter combinations. @@ -291,14 +291,6 @@ class RasterFieldTest(TransactionTestCas qs = RasterModel.objects.filter(rastprojected__dwithin=(rast, D(km=1))) self.assertEqual(qs.count(), 1) - qs = RasterModel.objects.filter( - rastprojected__dwithin=(json.loads(JSON_RASTER), D(km=1)) - ) - self.assertEqual(qs.count(), 1) - - qs = RasterModel.objects.filter(rastprojected__dwithin=(JSON_RASTER, D(km=1))) - self.assertEqual(qs.count(), 1) - # Filter in an unprojected coordinate system. qs = RasterModel.objects.filter(rast__dwithin=(rast, 40)) self.assertEqual(qs.count(), 1) @@ -459,13 +451,8 @@ class RasterFieldTest(TransactionTestCas self.assertEqual(qs.count(), 0) def test_lookup_value_error(self): - # Test with invalid dict lookup parameter - obj = {} - msg = "Couldn't create spatial object from lookup value '%s'." % obj - with self.assertRaisesMessage(ValueError, msg): - RasterModel.objects.filter(geom__intersects=obj) # Test with invalid string lookup parameter - obj = "00000" + obj = "POINT()" msg = "Couldn't create spatial object from lookup value '%s'." % obj with self.assertRaisesMessage(ValueError, msg): RasterModel.objects.filter(geom__intersects=obj) @@ -494,7 +481,7 @@ class RasterFieldTest(TransactionTestCas def test_lhs_with_index_rhs_without_index(self): with CaptureQueriesContext(connection) as queries: RasterModel.objects.filter( - rast__0__contains=json.loads(JSON_RASTER) + rast__0__contains=GDALRaster(json.loads(JSON_RASTER)) ).exists() # It's easier to check the indexes in the generated SQL than to write # tests that cover all index combinations. Index: Django-4.2.11/tests/gis_tests/test_geoforms.py =================================================================== --- Django-4.2.11.orig/tests/gis_tests/test_geoforms.py +++ Django-4.2.11/tests/gis_tests/test_geoforms.py @@ -8,6 +8,8 @@ from django.test import SimpleTestCase, from django.utils.deprecation import RemovedInDjango51Warning from django.utils.html import escape +from .data.rasters.textrasters import JSON_RASTER + class GeometryFieldTest(SimpleTestCase): def test_init(self): @@ -82,6 +84,16 @@ class GeometryFieldTest(SimpleTestCase): with self.assertRaises(ValidationError): pnt_fld.clean("LINESTRING(0 0, 1 1)") + def test_raster_types(self): + fld = forms.GeometryField() + msg = "Invalid geometry value." + for value in (JSON_RASTER, "/vsicurl/http://example.com/raster.tif"): + with ( + self.subTest(value=value), + self.assertRaisesMessage(ValidationError, msg), + ): + fld.clean(value) + def test_to_python(self): """ to_python() either returns a correct GEOSGeometry object or ++++++ CVE-2026-15337.patch ++++++ >From 7719284743eb90029e7f42f6634834955e304137 Mon Sep 17 00:00:00 2001 From: Natalia <[email protected]> Date: Fri, 10 Jul 2026 18:30:21 -0300 Subject: [PATCH 2/4] [5.2.x] Fixed CVE-2026-15337 -- Mitigated potential DoS in check_for_language(). Language codes longer than 500 characters are now rejected before the cached lookup, so they are no longer retained as cache keys consuming memory from each process. Thanks Jaeyoung Jang for the report, and Sarah Boyce for reviews. --- django/test/signals.py | 2 +- django/utils/translation/trans_real.py | 27 ++++++++++++++++++-------- docs/ref/utils.txt | 3 +++ docs/releases/5.2.17.txt | 19 ++++++++++++++++++ tests/i18n/tests.py | 24 ++++++++++++++++++++++- 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/django/test/signals.py b/django/test/signals.py index cb78b76114..17ebe497df 100644 --- a/django/test/signals.py +++ b/django/test/signals.py @@ -151,7 +151,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 86fe823bf7..06459860aa 100644 --- a/django/utils/translation/trans_real.py +++ b/django/utils/translation/trans_real.py @@ -31,9 +31,10 @@ _default = None # 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() @@ -458,19 +459,29 @@ def all_locale_paths(): return [globalpath, *settings.LOCALE_PATHS, *app_paths] [email protected]_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) + + [email protected]_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: <https://www.djangoproject.com/weblog/2007/oct/26/security-fix/>. """ # First, a quick check to make sure lang_code is well-formed (#21458) - if lang_code is None or not language_code_re.search(lang_code): + if not language_code_re.search(lang_code): return False return any( gettext_module.find("django", path, [to_locale(lang_code)]) is not None diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index 778012ce04..f7fd2f8bd5 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -1135,6 +1135,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/tests/i18n/tests.py b/tests/i18n/tests.py index 1f50ba1112..9334660820 100644 --- a/tests/i18n/tests.py +++ b/tests/i18n/tests.py @@ -59,7 +59,10 @@ from django.utils.translation.reloader import ( 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 @@ -2036,6 +2039,25 @@ class CountrySpecificLanguageTests(SimpleTestCase): 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) -- 2.43.0 ++++++ CVE-2026-15830.patch ++++++ ++++ 1096 lines (skipped) ++++++ CVE-2026-15920.patch ++++++ >From 3537f141913d4769042f43cca6200fd5eb4fab3c Mon Sep 17 00:00:00 2001 From: Natalia <[email protected]> Date: Mon, 13 Jul 2026 20:12:05 -0300 Subject: [PATCH 4/4] [5.2.x] Fixed CVE-2026-15920 -- Made display_for_field() validate URLs before rendering admin links. The admin renders URLField values as clickable links on changelists and read-only change forms. The link was built without validating the URL, so a potentially dangerous stored value could be rendered as a link that runs script in a staff member's authenticated session when clicked. The admin renders URLField values as clickable links on changelists and read-only change forms. The link was built without validating the URL, so a stored value using a potentially dangerous value was rendered as a link, which could lead to cross-site scripting in an authenticated admin session. Refs CVE-2019-12308, #36032. Thanks to Egor Saltykov for the report, and Sarah Boyce for reviews. --- django/contrib/admin/utils.py | 12 ++++++++++-- docs/releases/5.2.17.txt | 15 +++++++++++++++ tests/admin_utils/tests.py | 13 +++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) Index: Django-4.2.11/django/contrib/admin/utils.py =================================================================== --- Django-4.2.11.orig/django/contrib/admin/utils.py +++ Django-4.2.11/django/contrib/admin/utils.py @@ -3,7 +3,8 @@ import decimal import json from collections import defaultdict -from django.core.exceptions import FieldDoesNotExist +from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.core.validators import URLValidator from django.db import models, router from django.db.models.constants import LOOKUP_SEP from django.db.models.deletion import Collector @@ -426,6 +427,16 @@ def display_for_field(value, field, empt return formats.number_format(value) elif isinstance(field, models.FileField) and value: return format_html('<a href="{}">{}</a>', value.url, value) + elif isinstance(field, models.URLField) and value: + # 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('<a href="{}">{}</a>', value, value) elif isinstance(field, models.JSONField) and value: try: return json.dumps(value, ensure_ascii=False, cls=field.encoder) Index: Django-4.2.11/tests/admin_utils/tests.py =================================================================== --- Django-4.2.11.orig/tests/admin_utils/tests.py +++ Django-4.2.11/tests/admin_utils/tests.py @@ -200,6 +200,19 @@ class UtilsTests(SimpleTestCase): display_value, ) + 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,<script>alert(1)</script>", + ]: + with self.subTest(value=value): + display_value = display_for_field(value, model_field, self.empty_value) + self.assertNotIn("<a", display_value) + self.assertEqual(display_value, value) + def test_number_formats_display_for_field(self): display_value = display_for_field( 12345.6789, models.FloatField(), self.empty_value
