From 6d3f4c13f16c362a59c27ca55491c9bb06b98b59 Mon Sep 17 00:00:00 2001 From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> Date: Thu, 6 Mar 2025 10:07:14 +0100 Subject: [PATCH 01/33] [4.2.x] Post-release version bump. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index dddc14f597a5..7dc2e03a2e06 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,6 +1,6 @@ from django.utils.version import get_version -VERSION = (4, 2, 20, "final", 0) +VERSION = (4, 2, 21, "alpha", 0) __version__ = get_version(VERSION) From 506cf74b0ac3a61c1bc341f9beebf8f9c087a7e4 Mon Sep 17 00:00:00 2001 From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> Date: Thu, 6 Mar 2025 14:04:36 +0100 Subject: [PATCH 02/33] [4.2.x] Added CVE-2025-26699 to security archive. Backport of bad1a18ff28a671f2fdfd447bdf8f43602f882c2 from main. --- docs/releases/security.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/releases/security.txt b/docs/releases/security.txt index f997fe94a3a3..acc143770b11 100644 --- a/docs/releases/security.txt +++ b/docs/releases/security.txt @@ -36,6 +36,17 @@ Issues under Django's security process All security issues have been handled under versions of Django's security process. These are listed below. +March 6, 2025 - :cve:`2025-26699` +--------------------------------- + +Potential denial-of-service in ``django.utils.text.wrap()``. +`Full description +`__ + +* Django 5.1 :commit:`(patch) <8dbb44d34271637099258391dfc79df33951b841>` +* Django 5.0 :commit:`(patch) <4f2765232336b8ad0afd8017d9d912ae93470017>` +* Django 4.2 :commit:`(patch) ` + January 14, 2025 - :cve:`2024-56374` ------------------------------------ From 318c16d2b8157b0ca3fa5f69d0306409b57314b9 Mon Sep 17 00:00:00 2001 From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> Date: Fri, 4 Apr 2025 09:52:22 +0200 Subject: [PATCH 03/33] [4.2.x] Fixed #36298 -- Truncated the overwritten file content in file_move_safe(). Regression in 58cd4902a71a3695dd6c21dc957f59c333db364c. Thanks Baptiste Mispelon for the report. Backport of 8ad3e80e88201f4c557f6fa79fcfc0f8a0961830 from main. --- django/core/files/move.py | 1 + docs/releases/4.2.21.txt | 15 +++++++++++++++ docs/releases/index.txt | 1 + tests/files/tests.py | 21 +++++++++++++++++++++ 4 files changed, 38 insertions(+) create mode 100644 docs/releases/4.2.21.txt diff --git a/django/core/files/move.py b/django/core/files/move.py index 95d69f9d944c..44f91061f569 100644 --- a/django/core/files/move.py +++ b/django/core/files/move.py @@ -67,6 +67,7 @@ def file_move_safe( | os.O_CREAT | getattr(os, "O_BINARY", 0) | (os.O_EXCL if not allow_overwrite else 0) + | os.O_TRUNC ), ) try: diff --git a/docs/releases/4.2.21.txt b/docs/releases/4.2.21.txt new file mode 100644 index 000000000000..36e24df12f28 --- /dev/null +++ b/docs/releases/4.2.21.txt @@ -0,0 +1,15 @@ +=========================== +Django 4.2.21 release notes +=========================== + +*Expected May 7, 2025* + +Django 4.2.21 fixes a data loss bug in 4.2.20. + +Bugfixes +======== + +* Fixed a data corruption possibility in ``file_move_safe()`` when + ``allow_overwrite=True``, where leftover content from a previously larger + file could remain after overwriting with a smaller one due to lack of + truncation (:ticket:`36298`). diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 00e4465845f8..af5038a0950a 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -26,6 +26,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 4.2.21 4.2.20 4.2.19 4.2.18 diff --git a/tests/files/tests.py b/tests/files/tests.py index b3478d273204..99a289bee5ef 100644 --- a/tests/files/tests.py +++ b/tests/files/tests.py @@ -475,6 +475,27 @@ def test_file_move_permissionerror(self): os.close(handle_b) os.close(handle_c) + def test_file_move_ensure_truncation(self): + with tempfile.NamedTemporaryFile(delete=False) as src: + src.write(b"content") + src_name = src.name + self.addCleanup( + lambda: os.remove(src_name) if os.path.exists(src_name) else None + ) + + with tempfile.NamedTemporaryFile(delete=False) as dest: + dest.write(b"This is a longer content.") + dest_name = dest.name + self.addCleanup(os.remove, dest_name) + + with mock.patch("django.core.files.move.os.rename", side_effect=OSError()): + file_move_safe(src_name, dest_name, allow_overwrite=True) + + with open(dest_name, "rb") as f: + content = f.read() + + self.assertEqual(content, b"content") + class SpooledTempTests(unittest.TestCase): def test_in_memory_spooled_temp(self): From d5db532077fd4326d2908c257914ea2c4d27c6f1 Mon Sep 17 00:00:00 2001 From: nessita <124304+nessita@users.noreply.github.com> Date: Tue, 28 Jan 2025 22:17:40 -0300 Subject: [PATCH 04/33] [4.2.x] Pinned isort version to "<6.0.0" to avoid undesired reformat. Backport of 0671a461c44ba4cf97e84b6c88413bed332df314 from main. --- .github/workflows/linters.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 8f95264b9e70..197b9628894a 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -45,7 +45,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: '3.11' - - run: python -m pip install isort + - run: python -m pip install "isort<6" - name: isort # Pinned to v2.0.0. uses: liskin/gh-problem-matcher-wrap@d8afa2cfb66dd3f982b1950429e652bc14d0d7d2 From 24eeba2c1584a79724fc83d5527f61c434c0f523 Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Sun, 30 Mar 2025 17:54:15 +0200 Subject: [PATCH 05/33] [4.2.x] Fixed warnings per flake8 7.2.0. https://github.com/PyCQA/flake8/releases/tag/7.2.0 Backport of 281910ff8e9ae98fa78ee5d26ae3f0b713ccf418 from main. --- django/contrib/gis/db/models/fields.py | 2 -- django/db/backends/base/base.py | 1 - django/utils/autoreload.py | 1 - django/utils/translation/trans_real.py | 1 - 4 files changed, 5 deletions(-) diff --git a/django/contrib/gis/db/models/fields.py b/django/contrib/gis/db/models/fields.py index 889c1cfe840c..812029de49dd 100644 --- a/django/contrib/gis/db/models/fields.py +++ b/django/contrib/gis/db/models/fields.py @@ -37,8 +37,6 @@ def get_srid_info(srid, connection): """ from django.contrib.gis.gdal import SpatialReference - global _srid_cache - try: # The SpatialRefSys model for the spatial backend. SpatialRefSys = connection.ops.spatial_ref_sys() diff --git a/django/db/backends/base/base.py b/django/db/backends/base/base.py index 3b845ec9b37e..6830b3bec3cd 100644 --- a/django/db/backends/base/base.py +++ b/django/db/backends/base/base.py @@ -234,7 +234,6 @@ def get_new_connection(self, conn_params): def init_connection_state(self): """Initialize the database connection settings.""" - global RAN_DB_VERSION_CHECK if self.alias not in RAN_DB_VERSION_CHECK: self.check_database_version_supported() RAN_DB_VERSION_CHECK.add(self.alias) diff --git a/django/utils/autoreload.py b/django/utils/autoreload.py index 5b22aef2b1a1..cb63d521005f 100644 --- a/django/utils/autoreload.py +++ b/django/utils/autoreload.py @@ -82,7 +82,6 @@ def wrapper(*args, **kwargs): def raise_last_exception(): - global _exception if _exception is not None: raise _exception[1] diff --git a/django/utils/translation/trans_real.py b/django/utils/translation/trans_real.py index 6833b4bf7fd9..4677610e13d2 100644 --- a/django/utils/translation/trans_real.py +++ b/django/utils/translation/trans_real.py @@ -288,7 +288,6 @@ def translation(language): """ Return a translation object in the default 'django' domain. """ - global _translations if language not in _translations: _translations[language] = DjangoTranslation(language) return _translations[language] From 07edc976c7308a223e1b1dc3c62bd80b3bfef560 Mon Sep 17 00:00:00 2001 From: David Smith <39445562+smithdc1@users.noreply.github.com> Date: Wed, 27 Nov 2024 18:20:49 +0000 Subject: [PATCH 06/33] [4.2.x] Upgraded to Python 3.12, Ubuntu 24.04, and enabled fail_on_warning for docs builds. Backport of 73d532d9a92d4d472564f3251499a428d1da9835 from main. --- .github/workflows/docs.yml | 3 +-- .readthedocs.yml | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4206c58e3515..681e22a63d77 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -21,8 +21,7 @@ permissions: jobs: docs: - # OS must be the same as on djangoproject.com. - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 name: docs steps: - name: Checkout diff --git a/.readthedocs.yml b/.readthedocs.yml index bde8b64da0f0..915d51de46f9 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -4,12 +4,13 @@ version: 2 build: - os: ubuntu-20.04 + os: ubuntu-24.04 tools: - python: "3.8" + python: "3.12" sphinx: configuration: docs/conf.py + fail_on_warning: true python: install: From e61e3daaf037507211028494d61f24382be31e5a Mon Sep 17 00:00:00 2001 From: Matti Pohjanvirta Date: Sun, 20 Apr 2025 18:22:51 +0300 Subject: [PATCH 07/33] [4.2.x] Fixed #36341 -- Preserved whitespaces in wordwrap template filter. Regression in 55d89e25f4115c5674cdd9b9bcba2bb2bb6d820b. This work improves the django.utils.text.wrap() function to ensure that empty lines and lines with whitespace only are kept instead of being dropped. Thanks Matti Pohjanvirta for the report and fix. Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> Backport of 1e9db35836d42a3c72f3d1015c2f302eb6fee046 from main. --- django/utils/text.py | 13 +++++- .../filter_tests/test_wordwrap.py | 41 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/django/utils/text.py b/django/utils/text.py index 81ae88dc76d4..b018f2601fd2 100644 --- a/django/utils/text.py +++ b/django/utils/text.py @@ -102,10 +102,19 @@ def wrap(text, width): width=width, break_long_words=False, break_on_hyphens=False, + replace_whitespace=False, ) result = [] - for line in text.splitlines(True): - result.extend(wrapper.wrap(line)) + for line in text.splitlines(): + wrapped = wrapper.wrap(line) + if not wrapped: + # If `line` contains only whitespaces that are dropped, restore it. + result.append(line) + else: + result.extend(wrapped) + if text.endswith("\n"): + # If `text` ends with a newline, preserve it. + result.append("") return "\n".join(result) diff --git a/tests/template_tests/filter_tests/test_wordwrap.py b/tests/template_tests/filter_tests/test_wordwrap.py index 4afa1dd234f1..1692332e1eeb 100644 --- a/tests/template_tests/filter_tests/test_wordwrap.py +++ b/tests/template_tests/filter_tests/test_wordwrap.py @@ -89,3 +89,44 @@ def test_wrap_long_text(self): "I'm afraid", wordwrap(long_text, 10), ) + + def test_wrap_preserve_newlines(self): + cases = [ + ( + "this is a long paragraph of text that really needs to be wrapped\n\n" + "that is followed by another paragraph separated by an empty line\n", + "this is a long paragraph of\ntext that really needs to be\nwrapped\n\n" + "that is followed by another\nparagraph separated by an\nempty line\n", + 30, + ), + ("\n\n\n", "\n\n\n", 5), + ("\n\n\n\n\n\n", "\n\n\n\n\n\n", 5), + ] + for text, expected, width in cases: + with self.subTest(text=text): + self.assertEqual(wordwrap(text, width), expected) + + def test_wrap_preserve_whitespace(self): + width = 5 + width_spaces = " " * width + cases = [ + ( + f"first line\n{width_spaces}\nsecond line", + f"first\nline\n{width_spaces}\nsecond\nline", + ), + ( + "first line\n \t\t\t \nsecond line", + "first\nline\n \t\t\t \nsecond\nline", + ), + ( + f"first line\n{width_spaces}\nsecond line\n\nthird{width_spaces}\n", + f"first\nline\n{width_spaces}\nsecond\nline\n\nthird\n", + ), + ( + f"first line\n{width_spaces}{width_spaces}\nsecond line", + f"first\nline\n{width_spaces}{width_spaces}\nsecond\nline", + ), + ] + for text, expected in cases: + with self.subTest(text=text): + self.assertEqual(wordwrap(text, width), expected) From b3df75339904fb0bc5742d0b458ac59b8e68835b Mon Sep 17 00:00:00 2001 From: nessita <124304+nessita@users.noreply.github.com> Date: Wed, 23 Apr 2025 17:26:48 -0300 Subject: [PATCH 08/33] [4.2.x] Refs #36341 -- Added release note for 4.2.21 for fix in wordwrap template filter. Revision 1e9db35836d42a3c72f3d1015c2f302eb6fee046 fixed a regression in 55d89e25f4115c5674cdd9b9bcba2bb2bb6d820b, which also needs to be backported to the stable branches in extended support. Backport of c86242d61ff81bddbead115c458c1eb532d43b43 from main. --- docs/releases/4.2.21.txt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/releases/4.2.21.txt b/docs/releases/4.2.21.txt index 36e24df12f28..1064dcf2020f 100644 --- a/docs/releases/4.2.21.txt +++ b/docs/releases/4.2.21.txt @@ -4,7 +4,7 @@ Django 4.2.21 release notes *Expected May 7, 2025* -Django 4.2.21 fixes a data loss bug in 4.2.20. +Django 4.2.21 fixes a data loss bug and a regression in 4.2.20. Bugfixes ======== @@ -13,3 +13,8 @@ Bugfixes ``allow_overwrite=True``, where leftover content from a previously larger file could remain after overwriting with a smaller one due to lack of truncation (:ticket:`36298`). + +* Fixed a regression in Django 4.2.20, introduced when fixing + :cve:`2025-26699`, where the :tfilter:`wordwrap` template filter did not + preserve empty lines between paragraphs after wrapping text + (:ticket:`36341`). From 93973d4f88b46fe72e806361711eeefa83f8e535 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Wed, 30 Apr 2025 11:23:51 -0300 Subject: [PATCH 09/33] [4.2.x] Added upcoming security release to release notes. Backport of 0f5dd0dff3049189a3fe71a62670b746543335d5 from main. --- docs/releases/4.2.21.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/releases/4.2.21.txt b/docs/releases/4.2.21.txt index 1064dcf2020f..306269a3e7e0 100644 --- a/docs/releases/4.2.21.txt +++ b/docs/releases/4.2.21.txt @@ -4,7 +4,8 @@ Django 4.2.21 release notes *Expected May 7, 2025* -Django 4.2.21 fixes a data loss bug and a regression in 4.2.20. +Django 4.2.21 fixes a security issue with severity "moderate", a data loss bug, +and a regression in 4.2.20. Bugfixes ======== From 35c34ed2d0039db98a871da300531514bb0cbac0 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Fri, 5 Jan 2024 06:03:19 +0100 Subject: [PATCH 10/33] [4.2.x] Removed obsolete rpm-related install code. Backport of edcf8532ffda006bc125d9c93fca59f9037f490f from main. --- scripts/rpm-install.sh | 28 ---------------------------- setup.cfg | 4 ---- 2 files changed, 32 deletions(-) delete mode 100644 scripts/rpm-install.sh diff --git a/scripts/rpm-install.sh b/scripts/rpm-install.sh deleted file mode 100644 index 89cf4dd04954..000000000000 --- a/scripts/rpm-install.sh +++ /dev/null @@ -1,28 +0,0 @@ -#! /bin/sh -# -# This file becomes the install section of the generated spec file. -# - -# This is what dist.py normally does. -%{__python} setup.py install --root=${RPM_BUILD_ROOT} --record="INSTALLED_FILES" - -# Sort the filelist so that directories appear before files. This avoids -# duplicate filename problems on some systems. -touch DIRS -for i in `cat INSTALLED_FILES`; do - if [ -f ${RPM_BUILD_ROOT}/$i ]; then - echo $i >>FILES - fi - if [ -d ${RPM_BUILD_ROOT}/$i ]; then - echo %dir $i >>DIRS - fi -done - -# Make sure we match foo.pyo and foo.pyc along with foo.py (but only once each) -sed -e "/\.py[co]$/d" -e "s/\.py$/.py*/" DIRS FILES >INSTALLED_FILES - -mkdir -p ${RPM_BUILD_ROOT}/%{_mandir}/man1/ -cp docs/man/* ${RPM_BUILD_ROOT}/%{_mandir}/man1/ -cat << EOF >> INSTALLED_FILES -%doc %{_mandir}/man1/*" -EOF diff --git a/setup.cfg b/setup.cfg index 574b02e2ba38..6c4dc6804394 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,10 +53,6 @@ console_scripts = argon2 = argon2-cffi >= 19.1.0 bcrypt = bcrypt -[bdist_rpm] -doc_files = docs extras AUTHORS INSTALL LICENSE README.rst -install_script = scripts/rpm-install.sh - [flake8] exclude = build,.git,.tox,./tests/.env extend-ignore = E203 From afe52d89c4f42870622a4bb161ab5f4d4913aac5 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Mon, 24 Jun 2024 20:34:43 +0200 Subject: [PATCH 11/33] [4.2.x] Migrated setuptools configuration to pyproject.toml. This branch migrates setuptools configuration from setup.py/setup.cfg to pyproject.toml. In order to ensure that the generated binary files have consistent casing (both the tarball and the wheel), setuptools version is limited to ">=61.0.0,<69.3.0". Configuration for flake8 was moved to a dedicated .flake8 file since it cannot be configured via pyproject.toml. Also, __pycache__ exclusion was removed from MANIFEST and the extras/Makefile was replaced with a simpler build command. Co-authored-by: Nick Pope Backport of 4686541691dbe986f58ac87630c3b7a04db4ff93 from main. --- .flake8 | 9 +++ MANIFEST.in | 1 - .../writing-code/coding-style.txt | 2 +- docs/internals/howto-release-django.txt | 18 +++-- docs/topics/auth/passwords.txt | 4 +- extras/Makefile | 9 --- pyproject.toml | 72 +++++++++++++++++-- setup.cfg | 69 ------------------ setup.py | 55 -------------- 9 files changed, 92 insertions(+), 147 deletions(-) create mode 100644 .flake8 delete mode 100644 extras/Makefile delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000000..c4094af4621c --- /dev/null +++ b/.flake8 @@ -0,0 +1,9 @@ +[flake8] +exclude = build,.git,.tox,./tests/.env +extend-ignore = E203 +max-line-length = 88 +per-file-ignores = + django/core/cache/backends/filebased.py:W601 + django/core/cache/backends/base.py:W601 + django/core/cache/backends/redis.py:W601 + tests/cache/tests.py:W601 diff --git a/MANIFEST.in b/MANIFEST.in index fecbae358b81..cba764b41419 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -12,5 +12,4 @@ graft extras graft js_tests graft scripts graft tests -global-exclude __pycache__ global-exclude *.py[co] diff --git a/docs/internals/contributing/writing-code/coding-style.txt b/docs/internals/contributing/writing-code/coding-style.txt index d227e04ba0fe..0a023ed39244 100644 --- a/docs/internals/contributing/writing-code/coding-style.txt +++ b/docs/internals/contributing/writing-code/coding-style.txt @@ -46,7 +46,7 @@ Python style * Unless otherwise specified, follow :pep:`8`. Use :pypi:`flake8` to check for problems in this area. Note that our - ``setup.cfg`` file contains some excluded files (deprecated modules we don't + ``.flake8`` file contains some excluded files (deprecated modules we don't care about cleaning up and some third-party code that Django vendors) as well as some excluded errors that we don't consider as gross violations. Remember that :pep:`8` is only a guide, so respect the style of the surrounding code diff --git a/docs/internals/howto-release-django.txt b/docs/internals/howto-release-django.txt index 5c2d0b7451dd..ba70921ad243 100644 --- a/docs/internals/howto-release-django.txt +++ b/docs/internals/howto-release-django.txt @@ -58,7 +58,7 @@ You'll need a few things before getting started: .. code-block:: shell - $ python -m pip install wheel twine + $ python -m pip install build twine * Access to Django's record on PyPI. Create a file with your credentials: @@ -222,9 +222,15 @@ OK, this is the fun part, where we actually push out a release! Please see `notes on setting the VERSION tuple`_ below for details on ``VERSION``. -#. If this is a pre-release package, update the "Development Status" trove - classifier in ``setup.cfg`` to reflect this. Otherwise, make sure the - classifier is set to ``Development Status :: 5 - Production/Stable``. + #. If this is a pre-release package also update the "Development Status" + trove classifier in ``pyproject.toml`` to reflect this. An ``rc`` + pre-release should not change the trove classifier (:commit:`example + commit for alpha release `, + :commit:`example commit for beta release + <25fec8940b24107e21314ab6616e18ce8dec1c1c>`). + + #. Otherwise, make sure the classifier is set to + ``Development Status :: 5 - Production/Stable``. #. Tag the release using ``git tag``. For example: @@ -238,8 +244,8 @@ OK, this is the fun part, where we actually push out a release! #. Make sure you have an absolutely clean tree by running ``git clean -dfx``. -#. Run ``make -f extras/Makefile`` to generate the release packages. This will - create the release packages in a ``dist/`` directory. +#. Run ``python -m build`` to generate the release packages. This will create + the release packages in a ``dist/`` directory. #. Generate the hashes of the release packages: diff --git a/docs/topics/auth/passwords.txt b/docs/topics/auth/passwords.txt index b7b2dd4f7a41..636e9a2c326b 100644 --- a/docs/topics/auth/passwords.txt +++ b/docs/topics/auth/passwords.txt @@ -97,7 +97,7 @@ To use Argon2id as your default storage algorithm, do the following: #. Install the :pypi:`argon2-cffi` package. This can be done by running ``python -m pip install django[argon2]``, which is equivalent to ``python -m pip install argon2-cffi`` (along with any version requirement - from Django's ``setup.cfg``). + from Django's ``pyproject.toml``). #. Modify :setting:`PASSWORD_HASHERS` to list ``Argon2PasswordHasher`` first. That is, in your settings file, you'd put:: @@ -128,7 +128,7 @@ To use Bcrypt as your default storage algorithm, do the following: #. Install the :pypi:`bcrypt` package. This can be done by running ``python -m pip install django[bcrypt]``, which is equivalent to ``python -m pip install bcrypt`` (along with any version requirement from - Django's ``setup.cfg``). + Django's ``pyproject.toml``). #. Modify :setting:`PASSWORD_HASHERS` to list ``BCryptSHA256PasswordHasher`` first. That is, in your settings file, you'd put:: diff --git a/extras/Makefile b/extras/Makefile deleted file mode 100644 index 66efd0d45196..000000000000 --- a/extras/Makefile +++ /dev/null @@ -1,9 +0,0 @@ -all: sdist bdist_wheel - -sdist: - python setup.py sdist - -bdist_wheel: - python setup.py bdist_wheel - -.PHONY : sdist bdist_wheel diff --git a/pyproject.toml b/pyproject.toml index b1b79a53dd41..d059282f16df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,71 @@ [build-system] -requires = ['setuptools>=40.8.0'] -build-backend = 'setuptools.build_meta' +requires = ["setuptools>=61.0.0,<69.3.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "Django" +dynamic = ["version"] +requires-python = ">= 3.8" +dependencies = [ + "asgiref >= 3.6.0, < 4", + "backports.zoneinfo; python_version<'3.9'", + "sqlparse>=0.3.1", + "tzdata; sys_platform == 'win32'", +] +authors = [ + {name = "Django Software Foundation", email = "foundation@djangoproject.com"}, +] +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +readme = "README.rst" +license = {text = "BSD-3-Clause"} +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + "Topic :: Internet :: WWW/HTTP :: WSGI", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +[project.optional-dependencies] +argon2 = ["argon2-cffi>=19.1.0"] +bcrypt = ["bcrypt"] + +[project.scripts] +django-admin = "django.core.management:execute_from_command_line" + +[project.urls] +Homepage = "https://www.djangoproject.com/" +Documentation = "https://docs.djangoproject.com/" +"Release notes" = "https://docs.djangoproject.com/en/stable/releases/" +Funding = "https://www.djangoproject.com/fundraising/" +Source = "https://github.com/django/django" +Tracker = "https://code.djangoproject.com/" [tool.black] -target-version = ['py38'] -force-exclude = 'tests/test_runner_apps/tagged/tests_syntax_error.py' +target-version = ["py38"] +force-exclude = "tests/test_runner_apps/tagged/tests_syntax_error.py" + +[tool.isort] +profile = "black" +default_section = "THIRDPARTY" +known_first_party = "django" + +[tool.setuptools.dynamic] +version = {attr = "django.__version__"} + +[tool.setuptools.packages.find] +include = ["django*"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 6c4dc6804394..000000000000 --- a/setup.cfg +++ /dev/null @@ -1,69 +0,0 @@ -[metadata] -name = Django -version = attr: django.__version__ -url = https://www.djangoproject.com/ -author = Django Software Foundation -author_email = foundation@djangoproject.com -description = A high-level Python web framework that encourages rapid development and clean, pragmatic design. -long_description = file: README.rst -license = BSD-3-Clause -classifiers = - Development Status :: 5 - Production/Stable - Environment :: Web Environment - Framework :: Django - Intended Audience :: Developers - License :: OSI Approved :: BSD License - Operating System :: OS Independent - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.12 - Topic :: Internet :: WWW/HTTP - Topic :: Internet :: WWW/HTTP :: Dynamic Content - Topic :: Internet :: WWW/HTTP :: WSGI - Topic :: Software Development :: Libraries :: Application Frameworks - Topic :: Software Development :: Libraries :: Python Modules -project_urls = - Documentation = https://docs.djangoproject.com/ - Release notes = https://docs.djangoproject.com/en/stable/releases/ - Funding = https://www.djangoproject.com/fundraising/ - Source = https://github.com/django/django - Tracker = https://code.djangoproject.com/ - -[options] -python_requires = >=3.8 -packages = find: -include_package_data = true -zip_safe = false -install_requires = - asgiref >= 3.6.0, < 4 - backports.zoneinfo; python_version<"3.9" - sqlparse >= 0.3.1 - tzdata; sys_platform == 'win32' - -[options.entry_points] -console_scripts = - django-admin = django.core.management:execute_from_command_line - -[options.extras_require] -argon2 = argon2-cffi >= 19.1.0 -bcrypt = bcrypt - -[flake8] -exclude = build,.git,.tox,./tests/.env -extend-ignore = E203 -max-line-length = 88 -per-file-ignores = - django/core/cache/backends/filebased.py:W601 - django/core/cache/backends/base.py:W601 - django/core/cache/backends/redis.py:W601 - tests/cache/tests.py:W601 - -[isort] -profile = black -default_section = THIRDPARTY -known_first_party = django diff --git a/setup.py b/setup.py deleted file mode 100644 index ef91130d4738..000000000000 --- a/setup.py +++ /dev/null @@ -1,55 +0,0 @@ -import os -import site -import sys -from distutils.sysconfig import get_python_lib - -from setuptools import setup - -# Allow editable install into user site directory. -# See https://github.com/pypa/pip/issues/7953. -site.ENABLE_USER_SITE = "--user" in sys.argv[1:] - -# Warn if we are installing over top of an existing installation. This can -# cause issues where files that were deleted from a more recent Django are -# still present in site-packages. See #18115. -overlay_warning = False -if "install" in sys.argv: - lib_paths = [get_python_lib()] - if lib_paths[0].startswith("/usr/lib/"): - # We have to try also with an explicit prefix of /usr/local in order to - # catch Debian's custom user site-packages directory. - lib_paths.append(get_python_lib(prefix="/usr/local")) - for lib_path in lib_paths: - existing_path = os.path.abspath(os.path.join(lib_path, "django")) - if os.path.exists(existing_path): - # We note the need for the warning here, but present it after the - # command is run, so it's more likely to be seen. - overlay_warning = True - break - - -setup() - - -if overlay_warning: - sys.stderr.write( - """ - -======== -WARNING! -======== - -You have just installed Django over top of an existing -installation, without removing it first. Because of this, -your install may now include extraneous files from a -previous version that have since been removed from -Django. This is known to cause a variety of problems. You -should manually remove the - -%(existing_path)s - -directory and re-install Django. - -""" - % {"existing_path": existing_path} - ) From 3456eee4a3a00dc14e72d4f7d6eecc15ed9571e7 Mon Sep 17 00:00:00 2001 From: Nick Pope Date: Fri, 6 Dec 2024 18:32:39 +0000 Subject: [PATCH 12/33] [4.2.x] Fixed #35980 -- Updated setuptools to normalize package names in built artifacts. Backport of 3ae049b26b995c650c41ef918d5f60beed52b4ba from main. --- pyproject.toml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d059282f16df..4635d0e1f555 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,8 @@ [build-system] -requires = ["setuptools>=61.0.0,<69.3.0"] +requires = [ + "setuptools>=75.8.1; python_version >= '3.9'", + "setuptools<75.4.0; python_version < '3.9'", +] build-backend = "setuptools.build_meta" [project] @@ -7,8 +10,8 @@ name = "Django" dynamic = ["version"] requires-python = ">= 3.8" dependencies = [ - "asgiref >= 3.6.0, < 4", - "backports.zoneinfo; python_version<'3.9'", + "asgiref>=3.6.0,<4", + "backports.zoneinfo; python_version < '3.9'", "sqlparse>=0.3.1", "tzdata; sys_platform == 'win32'", ] From f4bd5647019bf4b4dc85dd9f05894e5c0e377e00 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Wed, 30 Apr 2025 15:16:07 -0300 Subject: [PATCH 13/33] [4.2.x] Adjusted GitHub Action workflow to test Python versions based off pyproject.toml. --- .github/workflows/python_matrix.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python_matrix.yml b/.github/workflows/python_matrix.yml index 314d9301b885..ab48c2be8322 100644 --- a/.github/workflows/python_matrix.yml +++ b/.github/workflows/python_matrix.yml @@ -25,7 +25,7 @@ jobs: uses: actions/checkout@v4 - id: set-matrix run: | - python_versions=$(sed -n "s/^.*Programming Language :: Python :: \([[:digit:]]\+\.[[:digit:]]\+\).*$/'\1', /p" setup.cfg | tr -d '\n' | sed 's/, $//g') + python_versions=$(sed -n "s/^.*Programming Language :: Python :: \([[:digit:]]\+\.[[:digit:]]\+\).*$/'\1', /p" pyproject.toml | tr -d '\n' | sed 's/, $//g') echo "Supported Python versions: $python_versions" echo "python_versions=[$python_versions]" >> "$GITHUB_OUTPUT" python: From ca31ca09f7ae5abab76012752a24a317544cdc2d Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Thu, 4 Jan 2024 23:05:05 +0100 Subject: [PATCH 14/33] [4.2.x] Changed packing recommendation to use pyproject.toml in reusable apps docs. Backport of f71bcc001bb3324020cfd756e84d4e9c6bb98cce from main. --- docs/intro/reusable-apps.txt | 133 +++++++++++++++-------------------- 1 file changed, 58 insertions(+), 75 deletions(-) diff --git a/docs/intro/reusable-apps.txt b/docs/intro/reusable-apps.txt index dba37286d326..4524c3df4e18 100644 --- a/docs/intro/reusable-apps.txt +++ b/docs/intro/reusable-apps.txt @@ -182,95 +182,78 @@ this. For a small app like polls, this process isn't too difficult. license. Just be aware that your licensing choice will affect who is able to use your code. -#. Next we'll create ``pyproject.toml``, ``setup.cfg``, and ``setup.py`` files - which detail how to build and install the app. A full explanation of these - files is beyond the scope of this tutorial, but the `setuptools - documentation `_ has a good - explanation. Create the ``django-polls/pyproject.toml``, - ``django-polls/setup.cfg``, and ``django-polls/setup.py`` files with the +#. Next we'll create the ``pyproject.toml`` file which details how to build and + install the app. A full explanation of this file is beyond the scope of this + tutorial, but the `Python Packaging User Guide + `_ has a good + explanation. Create the ``django-polls/pyproject.toml`` file with the following contents: .. code-block:: toml - :caption: ``django-polls/pyproject.toml`` - - [build-system] - requires = ['setuptools>=40.8.0'] - build-backend = 'setuptools.build_meta' - - .. code-block:: ini - :caption: ``django-polls/setup.cfg`` - - [metadata] - name = django-polls - version = 0.1 - description = A Django app to conduct web-based polls. - long_description = file: README.rst - url = https://www.example.com/ - author = Your Name - author_email = yourname@example.com - license = BSD-3-Clause # Example license - classifiers = - Environment :: Web Environment - Framework :: Django - Framework :: Django :: X.Y # Replace "X.Y" as appropriate - Intended Audience :: Developers - License :: OSI Approved :: BSD License - Operating System :: OS Independent - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.12 - Topic :: Internet :: WWW/HTTP - Topic :: Internet :: WWW/HTTP :: Dynamic Content - - [options] - include_package_data = true - packages = find: - python_requires = >=3.8 - install_requires = - Django >= X.Y # Replace "X.Y" as appropriate - - .. code-block:: python - :caption: ``django-polls/setup.py`` - - from setuptools import setup - - setup() - -#. Only Python modules and packages are included in the package by default. To - include additional files, we'll need to create a ``MANIFEST.in`` file. The - ``setuptools`` docs referred to in the previous step discuss this file in - more detail. To include the templates, the ``README.rst`` and our - ``LICENSE`` file, create a file ``django-polls/MANIFEST.in`` with the - following contents: + :caption: ``django-polls/pyproject.toml`` + + [build-system] + requires = ["setuptools>=61.0"] + build-backend = "setuptools.build_meta" + + [project] + name = "django-polls" + version = "0.1" + dependencies = [ + "django>=X.Y", # Replace "X.Y" as appropriate + ] + description = "A Django app to conduct web-based polls." + readme = "README.rst" + requires-python = ">= 3.8" + authors = [ + {name = "Your Name", email = "yourname@example.com"}, + ] + classifiers = [ + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: X.Y", # Replace "X.Y" as appropriate + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + ] + + [project.urls] + Homepage = "https://www.example.com/" + +#. Many common files and Python modules and packages are included in the + package by default. To include additional files, we'll need to create a + ``MANIFEST.in`` file. To include the templates and static files, create a + file ``django-polls/MANIFEST.in`` with the following contents: .. code-block:: text - :caption: ``django-polls/MANIFEST.in`` + :caption: ``django-polls/MANIFEST.in`` - include LICENSE - include README.rst - recursive-include polls/static * - recursive-include polls/templates * + recursive-include polls/static * + recursive-include polls/templates * #. It's optional, but recommended, to include detailed documentation with your app. Create an empty directory ``django-polls/docs`` for future - documentation. Add an additional line to ``django-polls/MANIFEST.in``: - - .. code-block:: text - - recursive-include docs * + documentation. Note that the ``docs`` directory won't be included in your package unless you add some files to it. Many Django apps also provide their documentation online through sites like `readthedocs.org `_. -#. Try building your package with ``python setup.py sdist`` (run from inside - ``django-polls``). This creates a directory called ``dist`` and builds your - new package, ``django-polls-0.1.tar.gz``. +#. Check that the :pypi:`build` package is installed (``python -m pip install + build``) and try building your package by running ``python -m build`` inside + ``django-polls``. This creates a directory called ``dist`` and builds your + new package into source and binary formats, ``django-polls-0.1.tar.gz`` and + ``django_polls-0.1-py3-none-any.whl``. For more information on packaging, see Python's `Tutorial on Packaging and Distributing Projects From 9cd8028f3e38dca8e51c1388f474eecbe7d6ca3c Mon Sep 17 00:00:00 2001 From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> Date: Tue, 8 Apr 2025 16:30:17 +0200 Subject: [PATCH 15/33] [4.2.x] Fixed CVE-2025-32873 -- Mitigated potential DoS in strip_tags(). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thanks to Elias Myllymäki for the report, and Shai Berger and Jake Howard for the reviews. Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> Backport of 9f3419b519799d69f2aba70b9d25abe2e70d03e0 from main. --- django/utils/html.py | 6 ++++++ docs/releases/4.2.21.txt | 11 +++++++++++ tests/utils_tests/test_html.py | 15 ++++++++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/django/utils/html.py b/django/utils/html.py index a3a7238cba44..84c37d118663 100644 --- a/django/utils/html.py +++ b/django/utils/html.py @@ -17,6 +17,9 @@ MAX_URL_LENGTH = 2048 MAX_STRIP_TAGS_DEPTH = 50 +# HTML tag that opens but has no closing ">" after 1k+ chars. +long_open_tag_without_closing_re = _lazy_re_compile(r"<[a-zA-Z][^>]{1000,}") + @keep_lazy(SafeString) def escape(text): @@ -175,6 +178,9 @@ def _strip_once(value): def strip_tags(value): """Return the given HTML with all tags stripped.""" value = str(value) + for long_open_tag in long_open_tag_without_closing_re.finditer(value): + if long_open_tag.group().count("<") >= MAX_STRIP_TAGS_DEPTH: + raise SuspiciousOperation # Note: in typical case this loop executes _strip_once twice (the second # execution does not remove any more tags). strip_tags_depth = 0 diff --git a/docs/releases/4.2.21.txt b/docs/releases/4.2.21.txt index 306269a3e7e0..cc39105a0167 100644 --- a/docs/releases/4.2.21.txt +++ b/docs/releases/4.2.21.txt @@ -7,6 +7,17 @@ Django 4.2.21 release notes Django 4.2.21 fixes a security issue with severity "moderate", a data loss bug, and a regression in 4.2.20. +CVE-2025-32873: Denial-of-service possibility in ``strip_tags()`` +================================================================= + +:func:`~django.utils.html.strip_tags` would be slow to evaluate certain inputs +containing large sequences of incomplete HTML tags. This function is used to +implement the :tfilter:`striptags` template filter, which was thus also +vulnerable. + +:func:`~django.utils.html.strip_tags` now raises a :exc:`.SuspiciousOperation` +exception if it encounters an unusually large number of unclosed opening tags. + Bugfixes ======== diff --git a/tests/utils_tests/test_html.py b/tests/utils_tests/test_html.py index 579bb2a1e359..25168e23487a 100644 --- a/tests/utils_tests/test_html.py +++ b/tests/utils_tests/test_html.py @@ -115,17 +115,30 @@ def test_strip_tags(self): (">br>br>br>X", "XX"), ("<" * 50 + "a>" * 50, ""), + (">" + "" + "" * 51, "" with self.assertRaises(SuspiciousOperation): strip_tags(value) + def test_strip_tags_suspicious_operation_large_open_tags(self): + items = [ + ">" + " Date: Tue, 6 May 2025 22:37:35 -0300 Subject: [PATCH 16/33] [4.2.x] Bumped version for 4.2.21 release. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index 7dc2e03a2e06..e29d3a8b9ac2 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,6 +1,6 @@ from django.utils.version import get_version -VERSION = (4, 2, 21, "alpha", 0) +VERSION = (4, 2, 21, "final", 0) __version__ = get_version(VERSION) From 9db70760578d508e3d99462938ed06d69d6c6dfc Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Tue, 6 May 2025 22:39:26 -0300 Subject: [PATCH 17/33] [4.2.x] Post-release version bump. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index e29d3a8b9ac2..6397342eea61 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,6 +1,6 @@ from django.utils.version import get_version -VERSION = (4, 2, 21, "final", 0) +VERSION = (4, 2, 22, "alpha", 0) __version__ = get_version(VERSION) From 0d5495850a5e4f2aca6dc908d3db2248d96750b2 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Wed, 7 May 2025 10:59:55 -0300 Subject: [PATCH 18/33] [4.2.x] Added CVE-2025-32873 to security archive. Backport of fdabda4e05587347aeb3382a442d7e77c1a0c3e5 from main. --- docs/releases/security.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/releases/security.txt b/docs/releases/security.txt index acc143770b11..9dc7b5c1ba5d 100644 --- a/docs/releases/security.txt +++ b/docs/releases/security.txt @@ -36,6 +36,17 @@ Issues under Django's security process All security issues have been handled under versions of Django's security process. These are listed below. +May 7, 2025 - :cve:`2025-32873` +------------------------------- + +Denial-of-service possibility in `strip_tags()`. +`Full description +`__ + +* Django 5.2 :commit:`(patch) ` +* Django 5.1 :commit:`(patch) <0b42f6a528df966729b24ecaaed67f85e5edc3dc>` +* Django 4.2 :commit:`(patch) <9cd8028f3e38dca8e51c1388f474eecbe7d6ca3c>` + March 6, 2025 - :cve:`2025-26699` --------------------------------- From 5b29315848450b7e7c5cdcd75096b1e172464330 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Wed, 7 May 2025 11:26:54 -0300 Subject: [PATCH 19/33] [4.2.x] Cleaned up CVE-2025-32873 security archive description. Backport of 37f2a77c729ccb71059c8e66c49b07499d2edf60 from main. --- docs/releases/security.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases/security.txt b/docs/releases/security.txt index 9dc7b5c1ba5d..ddf3d6224a94 100644 --- a/docs/releases/security.txt +++ b/docs/releases/security.txt @@ -39,7 +39,7 @@ process. These are listed below. May 7, 2025 - :cve:`2025-32873` ------------------------------- -Denial-of-service possibility in `strip_tags()`. +Denial-of-service possibility in ``strip_tags()``. `Full description `__ From c454afbf4c0461fd90f75391dc690b154abba6ef Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Thu, 8 May 2025 08:50:02 -0300 Subject: [PATCH 20/33] [4.2.x] Removed "Expected" from release date for 4.2.21. Backport of c86156378db09e68db3a9ae1c108f661a67e3abe from main. --- docs/releases/4.2.21.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases/4.2.21.txt b/docs/releases/4.2.21.txt index cc39105a0167..cd1d26d779b5 100644 --- a/docs/releases/4.2.21.txt +++ b/docs/releases/4.2.21.txt @@ -2,7 +2,7 @@ Django 4.2.21 release notes =========================== -*Expected May 7, 2025* +*May 7, 2025* Django 4.2.21 fixes a security issue with severity "moderate", a data loss bug, and a regression in 4.2.20. From dc365cac9b48067d8fa75968fc2e9801293eecb2 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Thu, 8 May 2025 09:06:55 -0300 Subject: [PATCH 21/33] [4.2.x] Refs #35980 -- Added release note about changes in release artifacts filenames. Backport of 42ab99309d347f617d60751c2e8d627fb2963049 from main. --- docs/releases/4.2.21.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/releases/4.2.21.txt b/docs/releases/4.2.21.txt index cd1d26d779b5..fa59deff06ce 100644 --- a/docs/releases/4.2.21.txt +++ b/docs/releases/4.2.21.txt @@ -7,6 +7,11 @@ Django 4.2.21 release notes Django 4.2.21 fixes a security issue with severity "moderate", a data loss bug, and a regression in 4.2.20. +This release was built using an upgraded :pypi:`setuptools`, producing +filenames compliant with :pep:`491` and :pep:`625` and thus addressing a PyPI +warning about non-compliant distribution filenames. This change only affects +the Django packaging process and does not impact Django's behavior. + CVE-2025-32873: Denial-of-service possibility in ``strip_tags()`` ================================================================= From acbe655a0fa1200d2de31c6020f310ba9aa2f636 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Mon, 19 May 2025 22:45:38 -0300 Subject: [PATCH 22/33] [4.2.x] Refs #26688 -- Added tests for `log_response()` internal helper. Backport of 897046815944cc9a2da7ed9e8082f45ffe8110e3 from main. --- tests/logging_tests/tests.py | 121 +++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/tests/logging_tests/tests.py b/tests/logging_tests/tests.py index c73a3acd6d72..2138a7fe50f3 100644 --- a/tests/logging_tests/tests.py +++ b/tests/logging_tests/tests.py @@ -1,6 +1,7 @@ import logging from contextlib import contextmanager from io import StringIO +from unittest import TestCase from admin_scripts.tests import AdminScriptTestCase @@ -9,6 +10,7 @@ from django.core.exceptions import DisallowedHost, PermissionDenied, SuspiciousOperation from django.core.files.temp import NamedTemporaryFile from django.core.management import color +from django.http import HttpResponse from django.http.multipartparser import MultiPartParserError from django.test import RequestFactory, SimpleTestCase, override_settings from django.test.utils import LoggingCaptureMixin @@ -19,6 +21,7 @@ RequireDebugFalse, RequireDebugTrue, ServerFormatter, + log_response, ) from django.views.debug import ExceptionReporter @@ -646,3 +649,121 @@ def patch_django_server_logger(): self.assertRegex( logger_output.getvalue(), r"^\[[/:,\w\s\d]+\] %s\n" % log_msg ) + + +class LogResponseRealLoggerTests(TestCase): + request = RequestFactory().get("/test-path/") + + def assertResponseLogged(self, logger_cm, msg, levelno, status_code, request): + self.assertEqual( + records_len := len(logger_cm.records), + 1, + f"Unexpected number of records for {logger_cm=} in {levelno=} (expected 1, " + f"got {records_len}).", + ) + record = logger_cm.records[0] + self.assertEqual(record.getMessage(), msg) + self.assertEqual(record.levelno, levelno) + self.assertEqual(record.status_code, status_code) + self.assertEqual(record.request, request) + + def test_missing_response_raises_attribute_error(self): + with self.assertRaises(AttributeError): + log_response("No response provided", response=None, request=self.request) + + def test_missing_request_logs_with_none(self): + response = HttpResponse(status=403) + with self.assertLogs("django.request", level="INFO") as cm: + log_response(msg := "Missing request", response=response, request=None) + self.assertResponseLogged(cm, msg, logging.WARNING, 403, request=None) + + def test_logs_5xx_as_error(self): + response = HttpResponse(status=508) + with self.assertLogs("django.request", level="ERROR") as cm: + log_response( + msg := "Server error occurred", response=response, request=self.request + ) + self.assertResponseLogged(cm, msg, logging.ERROR, 508, self.request) + + def test_logs_4xx_as_warning(self): + response = HttpResponse(status=418) + with self.assertLogs("django.request", level="WARNING") as cm: + log_response( + msg := "This is a teapot!", response=response, request=self.request + ) + self.assertResponseLogged(cm, msg, logging.WARNING, 418, self.request) + + def test_logs_2xx_as_info(self): + response = HttpResponse(status=201) + with self.assertLogs("django.request", level="INFO") as cm: + log_response(msg := "OK response", response=response, request=self.request) + self.assertResponseLogged(cm, msg, logging.INFO, 201, self.request) + + def test_custom_log_level(self): + response = HttpResponse(status=403) + with self.assertLogs("django.request", level="DEBUG") as cm: + log_response( + msg := "Debug level log", + response=response, + request=self.request, + level="debug", + ) + self.assertResponseLogged(cm, msg, logging.DEBUG, 403, self.request) + + def test_logs_only_once_per_response(self): + response = HttpResponse(status=500) + with self.assertLogs("django.request", level="ERROR") as cm: + log_response("First log", response=response, request=self.request) + log_response("Second log", response=response, request=self.request) + self.assertResponseLogged(cm, "First log", logging.ERROR, 500, self.request) + + def test_exc_info_output(self): + response = HttpResponse(status=500) + try: + raise ValueError("Simulated failure") + except ValueError as exc: + with self.assertLogs("django.request", level="ERROR") as cm: + log_response( + "With exception", + response=response, + request=self.request, + exception=exc, + ) + self.assertResponseLogged( + cm, "With exception", logging.ERROR, 500, self.request + ) + self.assertIn("ValueError", "\n".join(cm.output)) # Stack trace included + + def test_format_args_are_applied(self): + response = HttpResponse(status=500) + with self.assertLogs("django.request", level="ERROR") as cm: + log_response( + "Something went wrong: %s (%d)", + "DB error", + 42, + response=response, + request=self.request, + ) + msg = "Something went wrong: DB error (42)" + self.assertResponseLogged(cm, msg, logging.ERROR, 500, self.request) + + def test_logs_with_custom_logger(self): + handler = logging.StreamHandler(log_stream := StringIO()) + handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s")) + + custom_logger = logging.getLogger("my.custom.logger") + custom_logger.setLevel(logging.DEBUG) + custom_logger.addHandler(handler) + self.addCleanup(custom_logger.removeHandler, handler) + + response = HttpResponse(status=404) + log_response( + msg := "Handled by custom logger", + response=response, + request=self.request, + logger=custom_logger, + ) + + self.assertEqual( + f"WARNING:my.custom.logger:{msg}", log_stream.getvalue().strip() + ) From 32fd8dec5618bd09eccdeb9dbf512043193d68ef Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Mon, 19 May 2025 22:46:00 -0300 Subject: [PATCH 23/33] [4.2.x] Added helpers in csrf_tests and logging_tests to assert logs from `log_response()`. Backport of ad6f99889838ccc2c30b3c02ed3868c9b565e81b from main. --- tests/csrf_tests/tests.py | 53 ++++++++++++++++++------------------ tests/logging_tests/tests.py | 42 ++++++++++++++++++++-------- 2 files changed, 57 insertions(+), 38 deletions(-) diff --git a/tests/csrf_tests/tests.py b/tests/csrf_tests/tests.py index ba8f87d6ac74..b8d928151e7b 100644 --- a/tests/csrf_tests/tests.py +++ b/tests/csrf_tests/tests.py @@ -1,3 +1,4 @@ +import logging import re from django.conf import settings @@ -57,6 +58,21 @@ def assertMaskedSecretCorrect(self, masked_secret, secret): actual = _unmask_cipher_token(masked_secret) self.assertEqual(actual, secret) + def assertForbiddenReason( + self, response, logger_cm, reason, levelno=logging.WARNING + ): + self.assertEqual( + records_len := len(logger_cm.records), + 1, + f"Unexpected number of records for {logger_cm=} in {levelno=} (expected 1, " + f"got {records_len}).", + ) + record = logger_cm.records[0] + self.assertEqual(record.getMessage(), "Forbidden (%s): " % reason) + self.assertEqual(record.levelno, levelno) + self.assertEqual(record.status_code, 403) + self.assertEqual(response.status_code, 403) + class CsrfFunctionTests(CsrfFunctionTestMixin, SimpleTestCase): def test_unmask_cipher_token(self): @@ -347,8 +363,7 @@ def _check_bad_or_missing_cookie(self, cookie, expected): mw.process_request(req) with self.assertLogs("django.security.csrf", "WARNING") as cm: resp = mw.process_view(req, post_form_view, (), {}) - self.assertEqual(403, resp.status_code) - self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % expected) + self.assertForbiddenReason(resp, cm, expected) def test_no_csrf_cookie(self): """ @@ -373,9 +388,8 @@ def _check_bad_or_missing_token( mw.process_request(req) with self.assertLogs("django.security.csrf", "WARNING") as cm: resp = mw.process_view(req, post_form_view, (), {}) - self.assertEqual(403, resp.status_code) self.assertEqual(resp["Content-Type"], "text/html; charset=utf-8") - self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % expected) + self.assertForbiddenReason(resp, cm, expected) def test_csrf_cookie_bad_or_missing_token(self): """ @@ -480,18 +494,12 @@ def test_put_and_delete_rejected(self): mw = CsrfViewMiddleware(post_form_view) with self.assertLogs("django.security.csrf", "WARNING") as cm: resp = mw.process_view(req, post_form_view, (), {}) - self.assertEqual(403, resp.status_code) - self.assertEqual( - cm.records[0].getMessage(), "Forbidden (%s): " % REASON_NO_CSRF_COOKIE - ) + self.assertForbiddenReason(resp, cm, REASON_NO_CSRF_COOKIE) req = self._get_request(method="DELETE") with self.assertLogs("django.security.csrf", "WARNING") as cm: resp = mw.process_view(req, post_form_view, (), {}) - self.assertEqual(403, resp.status_code) - self.assertEqual( - cm.records[0].getMessage(), "Forbidden (%s): " % REASON_NO_CSRF_COOKIE - ) + self.assertForbiddenReason(resp, cm, REASON_NO_CSRF_COOKIE) def test_put_and_delete_allowed(self): """ @@ -879,11 +887,7 @@ def test_reading_post_data_raises_unreadable_post_error(self): mw.process_request(req) with self.assertLogs("django.security.csrf", "WARNING") as cm: resp = mw.process_view(req, post_form_view, (), {}) - self.assertEqual(resp.status_code, 403) - self.assertEqual( - cm.records[0].getMessage(), - "Forbidden (%s): " % REASON_CSRF_TOKEN_MISSING, - ) + self.assertForbiddenReason(resp, cm, REASON_CSRF_TOKEN_MISSING) def test_reading_post_data_raises_os_error(self): """ @@ -908,9 +912,8 @@ def test_bad_origin_bad_domain(self): self.assertIs(mw._origin_verified(req), False) with self.assertLogs("django.security.csrf", "WARNING") as cm: response = mw.process_view(req, post_form_view, (), {}) - self.assertEqual(response.status_code, 403) msg = REASON_BAD_ORIGIN % req.META["HTTP_ORIGIN"] - self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg) + self.assertForbiddenReason(response, cm, msg) @override_settings(ALLOWED_HOSTS=["www.example.com"]) def test_bad_origin_null_origin(self): @@ -923,9 +926,8 @@ def test_bad_origin_null_origin(self): self.assertIs(mw._origin_verified(req), False) with self.assertLogs("django.security.csrf", "WARNING") as cm: response = mw.process_view(req, post_form_view, (), {}) - self.assertEqual(response.status_code, 403) msg = REASON_BAD_ORIGIN % req.META["HTTP_ORIGIN"] - self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg) + self.assertForbiddenReason(response, cm, msg) @override_settings(ALLOWED_HOSTS=["www.example.com"]) def test_bad_origin_bad_protocol(self): @@ -939,9 +941,8 @@ def test_bad_origin_bad_protocol(self): self.assertIs(mw._origin_verified(req), False) with self.assertLogs("django.security.csrf", "WARNING") as cm: response = mw.process_view(req, post_form_view, (), {}) - self.assertEqual(response.status_code, 403) msg = REASON_BAD_ORIGIN % req.META["HTTP_ORIGIN"] - self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg) + self.assertForbiddenReason(response, cm, msg) @override_settings( ALLOWED_HOSTS=["www.example.com"], @@ -966,9 +967,8 @@ def test_bad_origin_csrf_trusted_origin_bad_protocol(self): self.assertIs(mw._origin_verified(req), False) with self.assertLogs("django.security.csrf", "WARNING") as cm: response = mw.process_view(req, post_form_view, (), {}) - self.assertEqual(response.status_code, 403) msg = REASON_BAD_ORIGIN % req.META["HTTP_ORIGIN"] - self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg) + self.assertForbiddenReason(response, cm, msg) self.assertEqual(mw.allowed_origins_exact, {"http://no-match.com"}) self.assertEqual( mw.allowed_origin_subdomains, @@ -992,9 +992,8 @@ def test_bad_origin_cannot_be_parsed(self): self.assertIs(mw._origin_verified(req), False) with self.assertLogs("django.security.csrf", "WARNING") as cm: response = mw.process_view(req, post_form_view, (), {}) - self.assertEqual(response.status_code, 403) msg = REASON_BAD_ORIGIN % req.META["HTTP_ORIGIN"] - self.assertEqual(cm.records[0].getMessage(), "Forbidden (%s): " % msg) + self.assertForbiddenReason(response, cm, msg) @override_settings(ALLOWED_HOSTS=["www.example.com"]) def test_good_origin_insecure(self): diff --git a/tests/logging_tests/tests.py b/tests/logging_tests/tests.py index 2138a7fe50f3..4ffa49a1b805 100644 --- a/tests/logging_tests/tests.py +++ b/tests/logging_tests/tests.py @@ -94,6 +94,28 @@ def test_django_logger_debug(self): class LoggingAssertionMixin: + + def assertLogRecord( + self, + logger_cm, + level, + msg, + status_code, + exc_class=None, + ): + self.assertEqual( + records_len := len(logger_cm.records), + 1, + f"Wrong number of calls for {logger_cm=} in {level=} (expected 1, got " + f"{records_len}).", + ) + record = logger_cm.records[0] + self.assertEqual(record.getMessage(), msg) + self.assertEqual(record.status_code, status_code) + if exc_class: + self.assertIsNotNone(record.exc_info) + self.assertEqual(record.exc_info[0], exc_class) + def assertLogsRequest( self, url, level, msg, status_code, logger="django.request", exc_class=None ): @@ -102,17 +124,7 @@ def assertLogsRequest( self.client.get(url) except views.UncaughtException: pass - self.assertEqual( - len(cm.records), - 1, - "Wrong number of calls for logger %r in %r level." % (logger, level), - ) - record = cm.records[0] - self.assertEqual(record.getMessage(), msg) - self.assertEqual(record.status_code, status_code) - if exc_class: - self.assertIsNotNone(record.exc_info) - self.assertEqual(record.exc_info[0], exc_class) + self.assertLogRecord(cm, level, msg, status_code, exc_class) @override_settings(DEBUG=True, ROOT_URLCONF="logging_tests.urls") @@ -135,6 +147,14 @@ def test_page_not_found_warning(self): msg="Not Found: /does_not_exist/", ) + async def test_async_page_not_found_warning(self): + logger = "django.request" + level = "WARNING" + with self.assertLogs(logger, level) as cm: + await self.async_client.get("/does_not_exist/") + + self.assertLogRecord(cm, level, "Not Found: /does_not_exist/", 404) + def test_page_not_found_raised(self): self.assertLogsRequest( url="/does_not_exist_raised/", From c5b42632c95fdaaa46e2b9b512bf39346e21abc9 Mon Sep 17 00:00:00 2001 From: Jason Judkins <34417573+jcjudkins@users.noreply.github.com> Date: Mon, 26 May 2025 11:33:29 -0400 Subject: [PATCH 24/33] [4.2.x] Fixed #36402, Refs #35980 -- Updated built package name in reusable apps tutorial for PEP 625. Backport of 1307b8a1cb05762147736d0f347792b33f645390 from main. --- docs/intro/reusable-apps.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/intro/reusable-apps.txt b/docs/intro/reusable-apps.txt index 4524c3df4e18..bc6e1e635c03 100644 --- a/docs/intro/reusable-apps.txt +++ b/docs/intro/reusable-apps.txt @@ -193,7 +193,7 @@ this. For a small app like polls, this process isn't too difficult. :caption: ``django-polls/pyproject.toml`` [build-system] - requires = ["setuptools>=61.0"] + requires = ["setuptools>=69.3"] build-backend = "setuptools.build_meta" [project] @@ -252,7 +252,7 @@ this. For a small app like polls, this process isn't too difficult. #. Check that the :pypi:`build` package is installed (``python -m pip install build``) and try building your package by running ``python -m build`` inside ``django-polls``. This creates a directory called ``dist`` and builds your - new package into source and binary formats, ``django-polls-0.1.tar.gz`` and + new package into source and binary formats, ``django_polls-0.1.tar.gz`` and ``django_polls-0.1-py3-none-any.whl``. For more information on packaging, see Python's `Tutorial on Packaging and @@ -282,7 +282,7 @@ working. We'll now fix this by installing our new ``django-polls`` package. .. code-block:: shell - python -m pip install --user django-polls/dist/django-polls-0.1.tar.gz + python -m pip install --user django-polls/dist/django_polls-0.1.tar.gz #. With luck, your Django project should now work correctly again. Run the server again to confirm this. From c62f4eeda774b10541154b9e980f5b981030c4a0 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Wed, 28 May 2025 10:03:06 -0300 Subject: [PATCH 25/33] [4.2.x] Added stub release notes and release date for 4.2.22. Backport of 1a744343999c9646912cee76ba0a2fa6ef5e6240 from main. --- docs/releases/4.2.22.txt | 7 +++++++ docs/releases/index.txt | 1 + 2 files changed, 8 insertions(+) create mode 100644 docs/releases/4.2.22.txt diff --git a/docs/releases/4.2.22.txt b/docs/releases/4.2.22.txt new file mode 100644 index 000000000000..83c49b787bb9 --- /dev/null +++ b/docs/releases/4.2.22.txt @@ -0,0 +1,7 @@ +=========================== +Django 4.2.22 release notes +=========================== + +*June 4, 2025* + +Django 4.2.22 fixes a security issue with severity "low" in 4.2.21. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index af5038a0950a..a903812eddb7 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -26,6 +26,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 4.2.22 4.2.21 4.2.20 4.2.19 From ac03c5e7df8680c61cdb0d3bdb8be9095dba841e Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Tue, 20 May 2025 15:29:52 -0300 Subject: [PATCH 26/33] [4.2.x] Fixed CVE-2025-48432 -- Escaped formatting arguments in `log_response()`. Suitably crafted requests containing a CRLF sequence in the request path may have allowed log injection, potentially corrupting log files, obscuring other attacks, misleading log post-processing tools, or forging log entries. To mitigate this, all positional formatting arguments passed to the logger are now escaped using "unicode_escape" encoding. Thanks to Seokchan Yoon (https://ch4n3.kr/) for the report. Co-authored-by: Carlton Gibson Co-authored-by: Jake Howard Backport of a07ebec5591e233d8bbb38b7d63f35c5479eef0e from main. --- django/utils/log.py | 7 +++- docs/releases/4.2.22.txt | 14 +++++++ tests/logging_tests/tests.py | 79 +++++++++++++++++++++++++++++++++++- 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/django/utils/log.py b/django/utils/log.py index fd0cc1bdc1ff..d7465f73d75c 100644 --- a/django/utils/log.py +++ b/django/utils/log.py @@ -238,9 +238,14 @@ def log_response( else: level = "info" + escaped_args = tuple( + a.encode("unicode_escape").decode("ascii") if isinstance(a, str) else a + for a in args + ) + getattr(logger, level)( message, - *args, + *escaped_args, extra={ "status_code": response.status_code, "request": request, diff --git a/docs/releases/4.2.22.txt b/docs/releases/4.2.22.txt index 83c49b787bb9..ba3cc332482f 100644 --- a/docs/releases/4.2.22.txt +++ b/docs/releases/4.2.22.txt @@ -5,3 +5,17 @@ Django 4.2.22 release notes *June 4, 2025* Django 4.2.22 fixes a security issue with severity "low" in 4.2.21. + +CVE-2025-48432: Potential log injection via unescaped request path +================================================================== + +Internal HTTP response logging used ``request.path`` directly, allowing control +characters (e.g. newlines or ANSI escape sequences) to be written unescaped +into logs. This could enable log injection or forgery, letting attackers +manipulate log appearance or structure, especially in logs processed by +external systems or viewed in terminals. + +Although this does not directly impact Django's security model, it poses risks +when logs are consumed or interpreted by other tools. To fix this, the internal +``django.utils.log.log_response()`` function now escapes all positional +formatting arguments using a safe encoding. diff --git a/tests/logging_tests/tests.py b/tests/logging_tests/tests.py index 4ffa49a1b805..cda0a62f2c16 100644 --- a/tests/logging_tests/tests.py +++ b/tests/logging_tests/tests.py @@ -94,7 +94,6 @@ def test_django_logger_debug(self): class LoggingAssertionMixin: - def assertLogRecord( self, logger_cm, @@ -147,6 +146,14 @@ def test_page_not_found_warning(self): msg="Not Found: /does_not_exist/", ) + def test_control_chars_escaped(self): + self.assertLogsRequest( + url="/%1B[1;31mNOW IN RED!!!1B[0m/", + level="WARNING", + status_code=404, + msg=r"Not Found: /\x1b[1;31mNOW IN RED!!!1B[0m/", + ) + async def test_async_page_not_found_warning(self): logger = "django.request" level = "WARNING" @@ -155,6 +162,16 @@ async def test_async_page_not_found_warning(self): self.assertLogRecord(cm, level, "Not Found: /does_not_exist/", 404) + async def test_async_control_chars_escaped(self): + logger = "django.request" + level = "WARNING" + with self.assertLogs(logger, level) as cm: + await self.async_client.get(r"/%1B[1;31mNOW IN RED!!!1B[0m/") + + self.assertLogRecord( + cm, level, r"Not Found: /\x1b[1;31mNOW IN RED!!!1B[0m/", 404 + ) + def test_page_not_found_raised(self): self.assertLogsRequest( url="/does_not_exist_raised/", @@ -686,6 +703,7 @@ def assertResponseLogged(self, logger_cm, msg, levelno, status_code, request): self.assertEqual(record.levelno, levelno) self.assertEqual(record.status_code, status_code) self.assertEqual(record.request, request) + return record def test_missing_response_raises_attribute_error(self): with self.assertRaises(AttributeError): @@ -787,3 +805,62 @@ def test_logs_with_custom_logger(self): self.assertEqual( f"WARNING:my.custom.logger:{msg}", log_stream.getvalue().strip() ) + + def test_unicode_escape_escaping(self): + test_cases = [ + # Control characters. + ("line\nbreak", "line\\nbreak"), + ("carriage\rreturn", "carriage\\rreturn"), + ("tab\tseparated", "tab\\tseparated"), + ("formfeed\f", "formfeed\\x0c"), + ("bell\a", "bell\\x07"), + ("multi\nline\ntext", "multi\\nline\\ntext"), + # Slashes. + ("slash\\test", "slash\\\\test"), + ("back\\slash", "back\\\\slash"), + # Quotes. + ('quote"test"', 'quote"test"'), + ("quote'test'", "quote'test'"), + # Accented, composed characters, emojis and symbols. + ("café", "caf\\xe9"), + ("e\u0301", "e\\u0301"), # e + combining acute + ("smile🙂", "smile\\U0001f642"), + ("weird ☃️", "weird \\u2603\\ufe0f"), + # Non-Latin alphabets. + ("Привет", "\\u041f\\u0440\\u0438\\u0432\\u0435\\u0442"), + ("你好", "\\u4f60\\u597d"), + # ANSI escape sequences. + ("escape\x1b[31mred\x1b[0m", "escape\\x1b[31mred\\x1b[0m"), + ( + "/\x1b[1;31mCAUTION!!YOU ARE PWNED\x1b[0m/", + "/\\x1b[1;31mCAUTION!!YOU ARE PWNED\\x1b[0m/", + ), + ( + "/\r\n\r\n1984-04-22 INFO Listening on 0.0.0.0:8080\r\n\r\n", + "/\\r\\n\\r\\n1984-04-22 INFO Listening on 0.0.0.0:8080\\r\\n\\r\\n", + ), + # Plain safe input. + ("normal-path", "normal-path"), + ("slash/colon:", "slash/colon:"), + # Non strings. + (0, "0"), + ([1, 2, 3], "[1, 2, 3]"), + ({"test": "🙂"}, "{'test': '🙂'}"), + ] + + msg = "Test message: %s" + for case, expected in test_cases: + with self.assertLogs("django.request", level="ERROR") as cm: + with self.subTest(case=case): + response = HttpResponse(status=318) + log_response(msg, case, response=response, level="error") + + record = self.assertResponseLogged( + cm, + msg % expected, + levelno=logging.ERROR, + status_code=318, + request=None, + ) + # Log record is always a single line. + self.assertEqual(len(record.getMessage().splitlines()), 1) From 7275cc5d1326fad562725ed47fbe5eb149dfa6fb Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Wed, 4 Jun 2025 08:51:01 -0300 Subject: [PATCH 27/33] [4.2.x] Bumped version for 4.2.22 release. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index 6397342eea61..60f2bdc6e97e 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,6 +1,6 @@ from django.utils.version import get_version -VERSION = (4, 2, 22, "alpha", 0) +VERSION = (4, 2, 22, "final", 0) __version__ = get_version(VERSION) From 8d87045d8f312c5d8d9779b54831fabc320a27a3 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Wed, 4 Jun 2025 08:52:32 -0300 Subject: [PATCH 28/33] [4.2.x] Post-release version bump. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index 60f2bdc6e97e..9697a03158f3 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,6 +1,6 @@ from django.utils.version import get_version -VERSION = (4, 2, 22, "final", 0) +VERSION = (4, 2, 23, "alpha", 0) __version__ = get_version(VERSION) From b07f886af713c7d87f1a954560c6b2ff843f379c Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Wed, 4 Jun 2025 10:57:51 -0300 Subject: [PATCH 29/33] [4.2.x] Added CVE-2025-48432 to security archive. Backport of 51923c576a596ad00214e44028f9dee9748bce95 from main. --- docs/releases/security.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/releases/security.txt b/docs/releases/security.txt index ddf3d6224a94..de8fc96d6bc5 100644 --- a/docs/releases/security.txt +++ b/docs/releases/security.txt @@ -36,6 +36,17 @@ Issues under Django's security process All security issues have been handled under versions of Django's security process. These are listed below. +June 4, 2025 - :cve:`2025-48432` +-------------------------------- + +Potential log injection via unescaped request path. +`Full description +`__ + +* Django 5.2 :commit:`(patch) <7456aa23dafa149e65e62f95a6550cdb241d55ad>` +* Django 5.1 :commit:`(patch) <596542ddb46cdabe011322917e1655f0d24eece2>` +* Django 4.2 :commit:`(patch) ` + May 7, 2025 - :cve:`2025-32873` ------------------------------- From ba24ee34f98cb17d99b1f82bc1ede45ff311a70b Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Wed, 4 Jun 2025 16:12:13 -0300 Subject: [PATCH 30/33] [4.2.x] Refactored logging_tests to reuse assertions for log records. Backport of 9d72e7daf7299ef1ece56fd657a02f77a469efe9 from main. --- tests/logging_tests/tests.py | 61 ++++++++++++++---------------------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/tests/logging_tests/tests.py b/tests/logging_tests/tests.py index cda0a62f2c16..03409094f23c 100644 --- a/tests/logging_tests/tests.py +++ b/tests/logging_tests/tests.py @@ -97,23 +97,28 @@ class LoggingAssertionMixin: def assertLogRecord( self, logger_cm, - level, msg, + levelno, status_code, + request=None, exc_class=None, ): self.assertEqual( records_len := len(logger_cm.records), 1, - f"Wrong number of calls for {logger_cm=} in {level=} (expected 1, got " + f"Wrong number of calls for {logger_cm=} in {levelno=} (expected 1, got " f"{records_len}).", ) record = logger_cm.records[0] self.assertEqual(record.getMessage(), msg) + self.assertEqual(record.levelno, levelno) self.assertEqual(record.status_code, status_code) + if request is not None: + self.assertEqual(record.request, request) if exc_class: self.assertIsNotNone(record.exc_info) self.assertEqual(record.exc_info[0], exc_class) + return record def assertLogsRequest( self, url, level, msg, status_code, logger="django.request", exc_class=None @@ -123,7 +128,9 @@ def assertLogsRequest( self.client.get(url) except views.UncaughtException: pass - self.assertLogRecord(cm, level, msg, status_code, exc_class) + self.assertLogRecord( + cm, msg, getattr(logging, level), status_code, exc_class=exc_class + ) @override_settings(DEBUG=True, ROOT_URLCONF="logging_tests.urls") @@ -155,21 +162,17 @@ def test_control_chars_escaped(self): ) async def test_async_page_not_found_warning(self): - logger = "django.request" - level = "WARNING" - with self.assertLogs(logger, level) as cm: + with self.assertLogs("django.request", "WARNING") as cm: await self.async_client.get("/does_not_exist/") - self.assertLogRecord(cm, level, "Not Found: /does_not_exist/", 404) + self.assertLogRecord(cm, "Not Found: /does_not_exist/", logging.WARNING, 404) async def test_async_control_chars_escaped(self): - logger = "django.request" - level = "WARNING" - with self.assertLogs(logger, level) as cm: + with self.assertLogs("django.request", "WARNING") as cm: await self.async_client.get(r"/%1B[1;31mNOW IN RED!!!1B[0m/") self.assertLogRecord( - cm, level, r"Not Found: /\x1b[1;31mNOW IN RED!!!1B[0m/", 404 + cm, r"Not Found: /\x1b[1;31mNOW IN RED!!!1B[0m/", logging.WARNING, 404 ) def test_page_not_found_raised(self): @@ -688,23 +691,9 @@ def patch_django_server_logger(): ) -class LogResponseRealLoggerTests(TestCase): +class LogResponseRealLoggerTests(LoggingAssertionMixin, TestCase): request = RequestFactory().get("/test-path/") - def assertResponseLogged(self, logger_cm, msg, levelno, status_code, request): - self.assertEqual( - records_len := len(logger_cm.records), - 1, - f"Unexpected number of records for {logger_cm=} in {levelno=} (expected 1, " - f"got {records_len}).", - ) - record = logger_cm.records[0] - self.assertEqual(record.getMessage(), msg) - self.assertEqual(record.levelno, levelno) - self.assertEqual(record.status_code, status_code) - self.assertEqual(record.request, request) - return record - def test_missing_response_raises_attribute_error(self): with self.assertRaises(AttributeError): log_response("No response provided", response=None, request=self.request) @@ -713,7 +702,7 @@ def test_missing_request_logs_with_none(self): response = HttpResponse(status=403) with self.assertLogs("django.request", level="INFO") as cm: log_response(msg := "Missing request", response=response, request=None) - self.assertResponseLogged(cm, msg, logging.WARNING, 403, request=None) + self.assertLogRecord(cm, msg, logging.WARNING, 403, request=None) def test_logs_5xx_as_error(self): response = HttpResponse(status=508) @@ -721,7 +710,7 @@ def test_logs_5xx_as_error(self): log_response( msg := "Server error occurred", response=response, request=self.request ) - self.assertResponseLogged(cm, msg, logging.ERROR, 508, self.request) + self.assertLogRecord(cm, msg, logging.ERROR, 508, self.request) def test_logs_4xx_as_warning(self): response = HttpResponse(status=418) @@ -729,13 +718,13 @@ def test_logs_4xx_as_warning(self): log_response( msg := "This is a teapot!", response=response, request=self.request ) - self.assertResponseLogged(cm, msg, logging.WARNING, 418, self.request) + self.assertLogRecord(cm, msg, logging.WARNING, 418, self.request) def test_logs_2xx_as_info(self): response = HttpResponse(status=201) with self.assertLogs("django.request", level="INFO") as cm: log_response(msg := "OK response", response=response, request=self.request) - self.assertResponseLogged(cm, msg, logging.INFO, 201, self.request) + self.assertLogRecord(cm, msg, logging.INFO, 201, self.request) def test_custom_log_level(self): response = HttpResponse(status=403) @@ -746,14 +735,14 @@ def test_custom_log_level(self): request=self.request, level="debug", ) - self.assertResponseLogged(cm, msg, logging.DEBUG, 403, self.request) + self.assertLogRecord(cm, msg, logging.DEBUG, 403, self.request) def test_logs_only_once_per_response(self): response = HttpResponse(status=500) with self.assertLogs("django.request", level="ERROR") as cm: log_response("First log", response=response, request=self.request) log_response("Second log", response=response, request=self.request) - self.assertResponseLogged(cm, "First log", logging.ERROR, 500, self.request) + self.assertLogRecord(cm, "First log", logging.ERROR, 500, self.request) def test_exc_info_output(self): response = HttpResponse(status=500) @@ -767,9 +756,7 @@ def test_exc_info_output(self): request=self.request, exception=exc, ) - self.assertResponseLogged( - cm, "With exception", logging.ERROR, 500, self.request - ) + self.assertLogRecord(cm, "With exception", logging.ERROR, 500, self.request) self.assertIn("ValueError", "\n".join(cm.output)) # Stack trace included def test_format_args_are_applied(self): @@ -783,7 +770,7 @@ def test_format_args_are_applied(self): request=self.request, ) msg = "Something went wrong: DB error (42)" - self.assertResponseLogged(cm, msg, logging.ERROR, 500, self.request) + self.assertLogRecord(cm, msg, logging.ERROR, 500, self.request) def test_logs_with_custom_logger(self): handler = logging.StreamHandler(log_stream := StringIO()) @@ -855,7 +842,7 @@ def test_unicode_escape_escaping(self): response = HttpResponse(status=318) log_response(msg, case, response=response, level="error") - record = self.assertResponseLogged( + record = self.assertLogRecord( cm, msg % expected, levelno=logging.ERROR, From 10ba3f78da2e22bd232dc085e2a8a7c293c3fb73 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Thu, 5 Jun 2025 10:07:17 -0300 Subject: [PATCH 31/33] [4.2.x] Refs CVE-2025-48432 -- Made SuspiciousOperation logging use log_response() for consistency. Backport of ff835f439cb1ecd8d74a24de12e3c03e5477dc9d from main. --- django/core/handlers/exception.py | 21 +++++++++++---------- tests/logging_tests/tests.py | 9 +++++++++ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/django/core/handlers/exception.py b/django/core/handlers/exception.py index a63291f3b94c..1243734705e8 100644 --- a/django/core/handlers/exception.py +++ b/django/core/handlers/exception.py @@ -116,16 +116,6 @@ def response_for_exception(request, exc): # exception would be raised. request._mark_post_parse_error() - # The request logger receives events for any problematic request - # The security logger receives events for all SuspiciousOperations - security_logger = logging.getLogger( - "django.security.%s" % exc.__class__.__name__ - ) - security_logger.error( - str(exc), - exc_info=exc, - extra={"status_code": 400, "request": request}, - ) if settings.DEBUG: response = debug.technical_500_response( request, *sys.exc_info(), status_code=400 @@ -134,6 +124,17 @@ def response_for_exception(request, exc): response = get_exception_response( request, get_resolver(get_urlconf()), 400, exc ) + # The logger is set to django.security, which specifically captures + # SuspiciousOperation events, unlike the default django.request logger. + security_logger = logging.getLogger(f"django.security.{exc.__class__.__name__}") + log_response( + str(exc), + exception=exc, + request=request, + response=response, + level="error", + logger=security_logger, + ) else: signals.got_request_exception.send(sender=None, request=request) diff --git a/tests/logging_tests/tests.py b/tests/logging_tests/tests.py index 03409094f23c..bc88749fb7c1 100644 --- a/tests/logging_tests/tests.py +++ b/tests/logging_tests/tests.py @@ -597,6 +597,15 @@ def test_suspicious_email_admins(self): self.assertEqual(len(mail.outbox), 1) self.assertIn("SuspiciousOperation at /suspicious/", mail.outbox[0].body) + def test_response_logged(self): + with self.assertLogs("django.security.SuspiciousOperation", "ERROR") as handler: + response = self.client.get("/suspicious/") + + self.assertLogRecord( + handler, "dubious", logging.ERROR, 400, request=response.wsgi_request + ) + self.assertEqual(response.status_code, 400) + class SettingsCustomLoggingTest(AdminScriptTestCase): """ From b597d46bb19c8567615e62029210dab16c70db7d Mon Sep 17 00:00:00 2001 From: Jake Howard Date: Wed, 4 Jun 2025 16:08:46 +0100 Subject: [PATCH 32/33] [4.2.x] Refs CVE-2025-48432 -- Prevented log injection in remaining response logging. Migrated remaining response-related logging to use the `log_response()` helper to avoid potential log injection, to ensure untrusted values like request paths are safely escaped. Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> Backport of 957951755259b412d5113333b32bf85871d29814 from main. --- django/views/generic/base.py | 15 ++++++------ docs/releases/4.2.23.txt | 14 +++++++++++ docs/releases/index.txt | 1 + tests/generic_views/test_base.py | 40 ++++++++++++++++++++++++++++++-- 4 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 docs/releases/4.2.23.txt diff --git a/django/views/generic/base.py b/django/views/generic/base.py index 8f8f9397e8c0..8412288be1ce 100644 --- a/django/views/generic/base.py +++ b/django/views/generic/base.py @@ -14,6 +14,7 @@ from django.urls import reverse from django.utils.decorators import classonlymethod from django.utils.functional import classproperty +from django.utils.log import log_response logger = logging.getLogger("django.request") @@ -143,13 +144,14 @@ def dispatch(self, request, *args, **kwargs): return handler(request, *args, **kwargs) def http_method_not_allowed(self, request, *args, **kwargs): - logger.warning( + response = HttpResponseNotAllowed(self._allowed_methods()) + log_response( "Method Not Allowed (%s): %s", request.method, request.path, - extra={"status_code": 405, "request": request}, + response=response, + request=request, ) - response = HttpResponseNotAllowed(self._allowed_methods()) if self.view_is_async: @@ -261,10 +263,9 @@ def get(self, request, *args, **kwargs): else: return HttpResponseRedirect(url) else: - logger.warning( - "Gone: %s", request.path, extra={"status_code": 410, "request": request} - ) - return HttpResponseGone() + response = HttpResponseGone() + log_response("Gone: %s", request.path, response=response, request=request) + return response def head(self, request, *args, **kwargs): return self.get(request, *args, **kwargs) diff --git a/docs/releases/4.2.23.txt b/docs/releases/4.2.23.txt new file mode 100644 index 000000000000..e4232f9beaad --- /dev/null +++ b/docs/releases/4.2.23.txt @@ -0,0 +1,14 @@ +=========================== +Django 4.2.23 release notes +=========================== + +*June 10, 2025* + +Django 4.2.23 fixes a potential log injection issue in 4.2.22. + +Bugfixes +======== + +* Fixed a log injection possibility by migrating remaining response logging + to ``django.utils.log.log_response()``, which safely escapes arguments such + as the request path to prevent unsafe log output (:cve:`2025-48432`). diff --git a/docs/releases/index.txt b/docs/releases/index.txt index a903812eddb7..73195b535edb 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -26,6 +26,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 4.2.23 4.2.22 4.2.21 4.2.20 diff --git a/tests/generic_views/test_base.py b/tests/generic_views/test_base.py index add485245a87..3cd1ae6a5c18 100644 --- a/tests/generic_views/test_base.py +++ b/tests/generic_views/test_base.py @@ -1,5 +1,8 @@ +import logging import time +from logging_tests.tests import LoggingAssertionMixin + from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse from django.test import RequestFactory, SimpleTestCase, override_settings @@ -63,7 +66,7 @@ def get(self, request): return self -class ViewTest(SimpleTestCase): +class ViewTest(LoggingAssertionMixin, SimpleTestCase): rf = RequestFactory() def _assert_simple(self, response): @@ -297,6 +300,25 @@ def test_direct_instantiation(self): response = view.dispatch(self.rf.head("/")) self.assertEqual(response.status_code, 405) + def test_method_not_allowed_response_logged(self): + for path, escaped in [ + ("/foo/", "/foo/"), + (r"/%1B[1;31mNOW IN RED!!!1B[0m/", r"/\x1b[1;31mNOW IN RED!!!1B[0m/"), + ]: + with self.subTest(path=path): + request = self.rf.get(path, REQUEST_METHOD="BOGUS") + with self.assertLogs("django.request", "WARNING") as handler: + response = SimpleView.as_view()(request) + + self.assertLogRecord( + handler, + f"Method Not Allowed (BOGUS): {escaped}", + logging.WARNING, + 405, + request, + ) + self.assertEqual(response.status_code, 405) + @override_settings(ROOT_URLCONF="generic_views.urls") class TemplateViewTest(SimpleTestCase): @@ -425,7 +447,7 @@ def test_extra_context(self): @override_settings(ROOT_URLCONF="generic_views.urls") -class RedirectViewTest(SimpleTestCase): +class RedirectViewTest(LoggingAssertionMixin, SimpleTestCase): rf = RequestFactory() def test_no_url(self): @@ -549,6 +571,20 @@ def test_direct_instantiation(self): response = view.dispatch(self.rf.head("/foo/")) self.assertEqual(response.status_code, 410) + def test_gone_response_logged(self): + for path, escaped in [ + ("/foo/", "/foo/"), + (r"/%1B[1;31mNOW IN RED!!!1B[0m/", r"/\x1b[1;31mNOW IN RED!!!1B[0m/"), + ]: + with self.subTest(path=path): + request = self.rf.get(path) + with self.assertLogs("django.request", "WARNING") as handler: + RedirectView().dispatch(request) + + self.assertLogRecord( + handler, f"Gone: {escaped}", logging.WARNING, 410, request + ) + class GetContextDataTest(SimpleTestCase): def test_get_context_data_super(self): From a698dc223be0e245c8e9cf347defc1892ae5e3ea Mon Sep 17 00:00:00 2001 From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> Date: Tue, 10 Jun 2025 11:51:54 +0200 Subject: [PATCH 33/33] [4.2.x] Bumped version for 4.2.23 release. --- django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/__init__.py b/django/__init__.py index 9697a03158f3..49e1e4201a23 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,6 +1,6 @@ from django.utils.version import get_version -VERSION = (4, 2, 23, "alpha", 0) +VERSION = (4, 2, 23, "final", 0) __version__ = get_version(VERSION)