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/.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/.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 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: 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: 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/django/__init__.py b/django/__init__.py index dddc14f597a5..49e1e4201a23 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, 23, "final", 0) __version__ = get_version(VERSION) 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/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/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/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/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/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/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/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] 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/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/intro/reusable-apps.txt b/docs/intro/reusable-apps.txt index dba37286d326..bc6e1e635c03 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>=69.3"] + 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 @@ -299,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. diff --git a/docs/releases/4.2.21.txt b/docs/releases/4.2.21.txt new file mode 100644 index 000000000000..fa59deff06ce --- /dev/null +++ b/docs/releases/4.2.21.txt @@ -0,0 +1,37 @@ +=========================== +Django 4.2.21 release notes +=========================== + +*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. + +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()`` +================================================================= + +: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 +======== + +* 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`). + +* 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`). diff --git a/docs/releases/4.2.22.txt b/docs/releases/4.2.22.txt new file mode 100644 index 000000000000..ba3cc332482f --- /dev/null +++ b/docs/releases/4.2.22.txt @@ -0,0 +1,21 @@ +=========================== +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/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 00e4465845f8..73195b535edb 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -26,6 +26,9 @@ 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 4.2.19 4.2.18 diff --git a/docs/releases/security.txt b/docs/releases/security.txt index f997fe94a3a3..de8fc96d6bc5 100644 --- a/docs/releases/security.txt +++ b/docs/releases/security.txt @@ -36,6 +36,39 @@ 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` +------------------------------- + +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` +--------------------------------- + +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` ------------------------------------ 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..4635d0e1f555 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,74 @@ [build-system] -requires = ['setuptools>=40.8.0'] -build-backend = 'setuptools.build_meta' +requires = [ + "setuptools>=75.8.1; python_version >= '3.9'", + "setuptools<75.4.0; python_version < '3.9'", +] +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/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 deleted file mode 100644 index 574b02e2ba38..000000000000 --- a/setup.cfg +++ /dev/null @@ -1,73 +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 - -[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 -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} - ) 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/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): 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): diff --git a/tests/logging_tests/tests.py b/tests/logging_tests/tests.py index c73a3acd6d72..bc88749fb7c1 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 @@ -91,6 +94,32 @@ def test_django_logger_debug(self): class LoggingAssertionMixin: + def assertLogRecord( + self, + logger_cm, + 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 {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 ): @@ -99,17 +128,9 @@ 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), + self.assertLogRecord( + cm, msg, getattr(logging, level), status_code, exc_class=exc_class ) - 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) @override_settings(DEBUG=True, ROOT_URLCONF="logging_tests.urls") @@ -132,6 +153,28 @@ 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): + with self.assertLogs("django.request", "WARNING") as cm: + await self.async_client.get("/does_not_exist/") + + self.assertLogRecord(cm, "Not Found: /does_not_exist/", logging.WARNING, 404) + + async def test_async_control_chars_escaped(self): + with self.assertLogs("django.request", "WARNING") as cm: + await self.async_client.get(r"/%1B[1;31mNOW IN RED!!!1B[0m/") + + self.assertLogRecord( + cm, r"Not Found: /\x1b[1;31mNOW IN RED!!!1B[0m/", logging.WARNING, 404 + ) + def test_page_not_found_raised(self): self.assertLogsRequest( url="/does_not_exist_raised/", @@ -554,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): """ @@ -646,3 +698,165 @@ def patch_django_server_logger(): self.assertRegex( logger_output.getvalue(), r"^\[[/:,\w\s\d]+\] %s\n" % log_msg ) + + +class LogResponseRealLoggerTests(LoggingAssertionMixin, TestCase): + request = RequestFactory().get("/test-path/") + + 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.assertLogRecord(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.assertLogRecord(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.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.assertLogRecord(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.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.assertLogRecord(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.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): + 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.assertLogRecord(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() + ) + + 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.assertLogRecord( + 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) 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) 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 = [ + ">" + "