diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index f590b5a7dc4d..a06c0ac6367e 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -59,4 +59,4 @@ jobs: - name: Checkout uses: actions/checkout@v4 - name: black - uses: psf/black@stable + uses: psf/black@24.10.0 diff --git a/.github/workflows/python_matrix.yml b/.github/workflows/python_matrix.yml new file mode 100644 index 000000000000..314d9301b885 --- /dev/null +++ b/.github/workflows/python_matrix.yml @@ -0,0 +1,52 @@ +name: Python Matrix from config file + +on: + pull_request: + types: [labeled, synchronize, opened, reopened] + paths-ignore: + - 'docs/**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + define-matrix: + if: contains(github.event.pull_request.labels.*.name, 'python-matrix') + runs-on: ubuntu-latest + outputs: + python_versions_output: ${{ steps.set-matrix.outputs.python_versions }} + steps: + - name: Checkout code + 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') + echo "Supported Python versions: $python_versions" + echo "python_versions=[$python_versions]" >> "$GITHUB_OUTPUT" + python: + runs-on: ubuntu-latest + needs: define-matrix + strategy: + matrix: + python-version: ${{ fromJson(needs.define-matrix.outputs.python_versions_output) }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: 'tests/requirements/py3.txt' + - name: Install libmemcached-dev for pylibmc + run: sudo apt-get install libmemcached-dev + - name: Install and upgrade packaging tools + run: python -m pip install --upgrade pip setuptools wheel + - run: python -m pip install -r tests/requirements/py3.txt -e . + - name: Run tests + run: python tests/runtests.py -v2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d6ea11e8e06f..00d9b65a59f9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.1.0 + rev: 24.10.0 hooks: - id: black exclude: \.py-tpl$ @@ -9,7 +9,7 @@ repos: hooks: - id: blacken-docs additional_dependencies: - - black==24.1.0 + - black==24.10.0 files: 'docs/.*\.txt$' - repo: https://github.com/PyCQA/isort rev: 5.13.2 diff --git a/django/__init__.py b/django/__init__.py index 85122852ebb3..52ed8a2c4550 100644 --- a/django/__init__.py +++ b/django/__init__.py @@ -1,6 +1,6 @@ from django.utils.version import get_version -VERSION = (5, 0, 13, "final", 0) +VERSION = (5, 0, 14, "final", 0) __version__ = get_version(VERSION) diff --git a/django/core/validators.py b/django/core/validators.py index fe8d46526ab5..14b89ff11e05 100644 --- a/django/core/validators.py +++ b/django/core/validators.py @@ -7,6 +7,7 @@ from django.core.exceptions import ValidationError from django.utils.deconstruct import deconstructible from django.utils.encoding import punycode +from django.utils.http import MAX_URL_LENGTH from django.utils.ipv6 import is_valid_ipv6_address from django.utils.regex_helper import _lazy_re_compile from django.utils.translation import gettext_lazy as _ @@ -104,7 +105,7 @@ class URLValidator(RegexValidator): message = _("Enter a valid URL.") schemes = ["http", "https", "ftp", "ftps"] unsafe_chars = frozenset("\t\r\n") - max_length = 2048 + max_length = MAX_URL_LENGTH def __init__(self, schemes=None, **kwargs): super().__init__(**kwargs) diff --git a/django/utils/html.py b/django/utils/html.py index d04e594d13a1..df0b3984df7b 100644 --- a/django/utils/html.py +++ b/django/utils/html.py @@ -11,7 +11,7 @@ from django.utils.deprecation import RemovedInDjango60Warning from django.utils.encoding import punycode from django.utils.functional import Promise, cached_property, keep_lazy, keep_lazy_text -from django.utils.http import RFC3986_GENDELIMS, RFC3986_SUBDELIMS +from django.utils.http import MAX_URL_LENGTH, RFC3986_GENDELIMS, RFC3986_SUBDELIMS from django.utils.regex_helper import _lazy_re_compile from django.utils.safestring import SafeData, SafeString, mark_safe from django.utils.text import normalize_newlines @@ -37,7 +37,6 @@ "spacer", } -MAX_URL_LENGTH = 2048 MAX_STRIP_TAGS_DEPTH = 50 diff --git a/django/utils/http.py b/django/utils/http.py index cfd982fc016d..cc340ce84977 100644 --- a/django/utils/http.py +++ b/django/utils/http.py @@ -37,6 +37,7 @@ RFC3986_GENDELIMS = ":/?#[]@" RFC3986_SUBDELIMS = "!$&'()*+,;=" +MAX_URL_LENGTH = 2048 def urlencode(query, doseq=False): @@ -273,7 +274,10 @@ def url_has_allowed_host_and_scheme(url, allowed_hosts, require_https=False): def _url_has_allowed_host_and_scheme(url, allowed_hosts, require_https=False): # Chrome considers any URL with more than two slashes to be absolute, but # urlparse is not so flexible. Treat any url with three slashes as unsafe. - if url.startswith("///"): + if url.startswith("///") or len(url) > MAX_URL_LENGTH: + # urlparse does not perform validation of inputs. Unicode normalization + # is very slow on Windows and can be a DoS attack vector. + # https://docs.python.org/3/library/urllib.parse.html#url-parsing-security return False try: url_info = urlparse(url) diff --git a/docs/releases/5.0.14.txt b/docs/releases/5.0.14.txt new file mode 100644 index 000000000000..230bfed652e0 --- /dev/null +++ b/docs/releases/5.0.14.txt @@ -0,0 +1,17 @@ +=========================== +Django 5.0.14 release notes +=========================== + +*April 2, 2025* + +Django 5.0.14 fixes a security issue with severity "moderate" in 5.0.13. + +CVE-2025-27556: Potential denial-of-service vulnerability in ``LoginView``, ``LogoutView``, and ``set_language()`` on Windows +============================================================================================================================= + +Python's :func:`NFKC normalization ` is slow on +Windows. As a consequence, :class:`~django.contrib.auth.views.LoginView`, +:class:`~django.contrib.auth.views.LogoutView`, and +:func:`~django.views.i18n.set_language` were subject to a potential +denial-of-service attack via certain inputs with a very large number of Unicode +characters. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 25e79aef280d..134001c061d4 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -25,6 +25,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 5.0.14 5.0.13 5.0.12 5.0.11 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` ------------------------------------ diff --git a/tests/requirements/py3.txt b/tests/requirements/py3.txt index aad91469ec33..467a086b4b36 100644 --- a/tests/requirements/py3.txt +++ b/tests/requirements/py3.txt @@ -2,7 +2,7 @@ aiosmtpd asgiref >= 3.7.0 argon2-cffi >= 19.2.0 bcrypt -black +black == 24.10.0 docutils >= 0.19 geoip2; python_version < '3.12' jinja2 >= 2.11.0 diff --git a/tests/utils_tests/test_http.py b/tests/utils_tests/test_http.py index 818c35e59727..20fe2c4c70d9 100644 --- a/tests/utils_tests/test_http.py +++ b/tests/utils_tests/test_http.py @@ -6,6 +6,7 @@ from django.test import SimpleTestCase from django.utils.datastructures import MultiValueDict from django.utils.http import ( + MAX_URL_LENGTH, base36_to_int, content_disposition_header, escape_leading_slashes, @@ -273,6 +274,21 @@ def test_secure_param_non_https_urls(self): False, ) + def test_max_url_length(self): + allowed_host = "example.com" + max_extra_characters = "é" * (MAX_URL_LENGTH - len(allowed_host) - 1) + max_length_boundary_url = f"{allowed_host}/{max_extra_characters}" + cases = [ + (max_length_boundary_url, True), + (max_length_boundary_url + "ú", False), + ] + for url, expected in cases: + with self.subTest(url=url): + self.assertIs( + url_has_allowed_host_and_scheme(url, allowed_hosts={allowed_host}), + expected, + ) + class URLSafeBase64Tests(unittest.TestCase): def test_roundtrip(self): diff --git a/tox.ini b/tox.ini index 2e0a3b421a22..9d412e53a707 100644 --- a/tox.ini +++ b/tox.ini @@ -37,7 +37,7 @@ commands = [testenv:black] basepython = python3 usedevelop = false -deps = black +deps = black == 24.10.0 changedir = {toxinidir} commands = black --check --diff .