From 951ffb3832cd83ba672c1e3deae2bda128eb9cca Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:52:41 -0300 Subject: [PATCH 1/5] Fixed CVE-2026-25673 -- Simplified URLField scheme detection. This simplicaftion mitigates a potential DoS in URLField on Windows. The usage of `urlsplit()` in `URLField.to_python()` was replaced with `str.partition(":")` for URL scheme detection. On Windows, `urlsplit()` performs Unicode normalization which is slow for certain characters, making `URLField` vulnerable to DoS via specially crafted POST payloads. Thanks Seokchan Yoon for the report, and Jake Howard and Shai Berger for the review. Refs #36923. Co-authored-by: Jacob Walls --- django/forms/fields.py | 42 ++++----- docs/releases/4.2.29.txt | 22 +++++ docs/releases/5.2.12.txt | 22 +++++ docs/releases/6.0.3.txt | 22 +++++ .../forms_tests/field_tests/test_urlfield.py | 85 ++++++++++++++++++- 5 files changed, 165 insertions(+), 28 deletions(-) diff --git a/django/forms/fields.py b/django/forms/fields.py index a5eee6831c81..6408be2f060c 100644 --- a/django/forms/fields.py +++ b/django/forms/fields.py @@ -12,7 +12,6 @@ import uuid from decimal import Decimal, DecimalException from io import BytesIO -from urllib.parse import urlsplit, urlunsplit from django.core import validators from django.core.exceptions import ValidationError @@ -780,33 +779,24 @@ def __init__(self, *, assume_scheme=None, **kwargs): super().__init__(strip=True, **kwargs) def to_python(self, value): - def split_url(url): - """ - Return a list of url parts via urlsplit(), or raise - ValidationError for some malformed URLs. - """ - try: - return list(urlsplit(url)) - except ValueError: - # urlsplit can raise a ValueError with some - # misformatted URLs. - raise ValidationError(self.error_messages["invalid"], code="invalid") - value = super().to_python(value) if value: - url_fields = split_url(value) - if not url_fields[0]: - # If no URL scheme given, add a scheme. - url_fields[0] = self.assume_scheme - if not url_fields[1]: - # Assume that if no domain is provided, that the path segment - # contains the domain. - url_fields[1] = url_fields[2] - url_fields[2] = "" - # Rebuild the url_fields list, since the domain segment may now - # contain the path too. - url_fields = split_url(urlunsplit(url_fields)) - value = urlunsplit(url_fields) + # Detect scheme via partition to avoid calling urlsplit() on + # potentially large or slow-to-normalize inputs. + scheme, sep, _ = value.partition(":") + if ( + not sep + or not scheme + or not scheme[0].isascii() + or not scheme[0].isalpha() + or "/" in scheme + ): + # No valid scheme found -- prepend the assumed scheme. Handle + # scheme-relative URLs ("//example.com") separately. + if value.startswith("//"): + value = self.assume_scheme + ":" + value + else: + value = self.assume_scheme + "://" + value return value diff --git a/docs/releases/4.2.29.txt b/docs/releases/4.2.29.txt index a3f3787cd6a7..b780264929de 100644 --- a/docs/releases/4.2.29.txt +++ b/docs/releases/4.2.29.txt @@ -6,3 +6,25 @@ Django 4.2.29 release notes Django 4.2.29 fixes a security issue with severity "moderate" and a security issue with severity "low" in 4.2.28. + +CVE-2026-25673: Potential denial-of-service vulnerability in ``URLField`` via Unicode normalization on Windows +============================================================================================================== + +The :class:`~django.forms.URLField` form field's ``to_python()`` method used +:func:`~urllib.parse.urlsplit` to determine whether to prepend a URL scheme to +the submitted value. On Windows, ``urlsplit()`` performs +:func:`NFKC normalization `, which can be +disproportionately slow for large inputs containing certain characters. + +``URLField.to_python()`` now uses a simplified scheme detection, avoiding +Unicode normalization entirely and deferring URL validation to the appropriate +layers. As a result, while leading and trailing whitespace is still stripped by +default, characters such as newlines, tabs, and other control characters within +the value are no longer handled by ``URLField.to_python()``. When using the +default :class:`~django.core.validators.URLValidator`, these values will +continue to raise :exc:`~django.core.exceptions.ValidationError` during +validation, but if you rely on custom validators, ensure they do not depend on +the previous behavior of ``URLField.to_python()``. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. diff --git a/docs/releases/5.2.12.txt b/docs/releases/5.2.12.txt index 9cbbf3836a78..be2c7bc80719 100644 --- a/docs/releases/5.2.12.txt +++ b/docs/releases/5.2.12.txt @@ -8,6 +8,28 @@ Django 5.2.12 fixes a security issue with severity "moderate" and a security issue with severity "low" in 5.2.11. It also fixes one bug related to support for Python 3.14. +CVE-2026-25673: Potential denial-of-service vulnerability in ``URLField`` via Unicode normalization on Windows +============================================================================================================== + +The :class:`~django.forms.URLField` form field's ``to_python()`` method used +:func:`~urllib.parse.urlsplit` to determine whether to prepend a URL scheme to +the submitted value. On Windows, ``urlsplit()`` performs +:func:`NFKC normalization `, which can be +disproportionately slow for large inputs containing certain characters. + +``URLField.to_python()`` now uses a simplified scheme detection, avoiding +Unicode normalization entirely and deferring URL validation to the appropriate +layers. As a result, while leading and trailing whitespace is still stripped by +default, characters such as newlines, tabs, and other control characters within +the value are no longer handled by ``URLField.to_python()``. When using the +default :class:`~django.core.validators.URLValidator`, these values will +continue to raise :exc:`~django.core.exceptions.ValidationError` during +validation, but if you rely on custom validators, ensure they do not depend on +the previous behavior of ``URLField.to_python()``. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. + Bugfixes ======== diff --git a/docs/releases/6.0.3.txt b/docs/releases/6.0.3.txt index 8777b1ef2196..6750385c1eea 100644 --- a/docs/releases/6.0.3.txt +++ b/docs/releases/6.0.3.txt @@ -7,6 +7,28 @@ Django 6.0.3 release notes Django 6.0.3 fixes a security issue with severity "moderate", a security issue with severity "low", and several bugs in 6.0.2. +CVE-2026-25673: Potential denial-of-service vulnerability in ``URLField`` via Unicode normalization on Windows +============================================================================================================== + +The :class:`~django.forms.URLField` form field's ``to_python()`` method used +:func:`~urllib.parse.urlsplit` to determine whether to prepend a URL scheme to +the submitted value. On Windows, ``urlsplit()`` performs +:func:`NFKC normalization `, which can be +disproportionately slow for large inputs containing certain characters. + +``URLField.to_python()`` now uses a simplified scheme detection, avoiding +Unicode normalization entirely and deferring URL validation to the appropriate +layers. As a result, while leading and trailing whitespace is still stripped by +default, characters such as newlines, tabs, and other control characters within +the value are no longer handled by ``URLField.to_python()``. When using the +default :class:`~django.core.validators.URLValidator`, these values will +continue to raise :exc:`~django.core.exceptions.ValidationError` during +validation, but if you rely on custom validators, ensure they do not depend on +the previous behavior of ``URLField.to_python()``. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. + Bugfixes ======== diff --git a/tests/forms_tests/field_tests/test_urlfield.py b/tests/forms_tests/field_tests/test_urlfield.py index f7d318fdc940..87d348968737 100644 --- a/tests/forms_tests/field_tests/test_urlfield.py +++ b/tests/forms_tests/field_tests/test_urlfield.py @@ -1,4 +1,5 @@ from django.core.exceptions import ValidationError +from django.core.validators import URLValidator from django.forms import URLField from django.test import SimpleTestCase @@ -72,6 +73,16 @@ def test_urlfield_clean(self): # IPv6. ("http://[12:34::3a53]/", "http://[12:34::3a53]/"), ("http://[a34:9238::]:8080/", "http://[a34:9238::]:8080/"), + # IPv6 without scheme. + ("[12:34::3a53]/", "https://[12:34::3a53]/"), + # IDN domain without scheme but with port. + ("ñandú.es:8080/", "https://ñandú.es:8080/"), + # Scheme-relative. + ("//example.com", "https://example.com"), + ("//example.com/path", "https://example.com/path"), + # Whitespace stripped. + ("\t\n//example.com \n\t\n", "https://example.com"), + ("\t\nhttp://example.com \n\t\n", "http://example.com"), ] for url, expected in tests: with self.subTest(url=url): @@ -102,10 +113,19 @@ def test_urlfield_clean_invalid(self): # even on domains that don't fail the domain label length check in # the regex. "http://%s" % ("X" * 200,), - # urlsplit() raises ValueError. + # Scheme prepend yields a structurally invalid URL. "////]@N.AN", - # Empty hostname. + # Scheme prepend yields an empty hostname. "#@A.bO", + # Known problematic unicode chars. + "http://" + "¾" * 200, + # Non-ASCII character before the first colon. + "¾:example.com", + # ASCII digit before the first colon. + "1http://example.com", + # Empty scheme. + "://example.com", + ":example.com", ] msg = "'Enter a valid URL.'" for value in tests: @@ -143,3 +163,64 @@ def test_urlfield_assume_scheme(self): self.assertEqual(f.clean("example.com"), "http://example.com") f = URLField(assume_scheme="https") self.assertEqual(f.clean("example.com"), "https://example.com") + + def test_urlfield_assume_scheme_when_colons(self): + f = URLField() + tests = [ + # Port number. + ("http://example.com:8080/", "http://example.com:8080/"), + ("https://example.com:443/path", "https://example.com:443/path"), + # Userinfo with password. + ("http://user:pass@example.com", "http://user:pass@example.com"), + ( + "http://user:pass@example.com:8080/", + "http://user:pass@example.com:8080/", + ), + # Colon in path segment. + ("http://example.com/path:segment", "http://example.com/path:segment"), + ("http://example.com/a:b/c:d", "http://example.com/a:b/c:d"), + # Colon in query string. + ("http://example.com/?key=val:ue", "http://example.com/?key=val:ue"), + # Colon in fragment. + ("http://example.com/#section:1", "http://example.com/#section:1"), + # IPv6 -- multiple colons in host. + ("http://[::1]/", "http://[::1]/"), + ("http://[2001:db8::1]/", "http://[2001:db8::1]/"), + ("http://[2001:db8::1]:8080/", "http://[2001:db8::1]:8080/"), + # Colons across multiple components. + ( + "http://user:pass@example.com:8080/path:x?q=a:b#id:1", + "http://user:pass@example.com:8080/path:x?q=a:b#id:1", + ), + # FTP with port and userinfo. + ( + "ftp://user:pass@ftp.example.com:21/file", + "ftp://user:pass@ftp.example.com:21/file", + ), + ( + "ftps://user:pass@ftp.example.com:990/", + "ftps://user:pass@ftp.example.com:990/", + ), + # Scheme-relative URLs, starts with "//". + ("//example.com:8080/path", "https://example.com:8080/path"), + ("//user:pass@example.com/", "https://user:pass@example.com/"), + ] + for value, expected in tests: + with self.subTest(value=value): + self.assertEqual(f.clean(value), expected) + + def test_custom_validator_longer_max_length(self): + + class CustomLongURLValidator(URLValidator): + max_length = 4096 + + class CustomURLField(URLField): + default_validators = [CustomLongURLValidator()] + + field = CustomURLField() + # A URL with 4096 chars is valid given the custom validator. + prefix = "https://example.com/" + url = prefix + "a" * (4096 - len(prefix)) + self.assertEqual(len(url), 4096) + # No ValidationError is raised. + field.clean(url) From 019e44f67a8dace67b786e2818938c8691132988 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Wed, 21 Jan 2026 18:03:20 -0300 Subject: [PATCH 2/5] Fixed CVE-2026-25674 -- Prevented potentially incorrect permissions on file system object creation. This fix introduces `safe_makedirs()` in the `os` utils as a safer alternative to `os.makedirs()` that avoids umask-related race conditions in multi-threaded environments. This is a workaround for https://github.com/python/cpython/issues/86533 and the solution is based on the fix being proposed for CPython. Co-authored-by: Gregory P. Smith <68491+gpshead@users.noreply.github.com> Co-authored-by: Zackery Spytz Refs CVE-2020-24583 and #31921. Thanks Tarek Nakkouch for the report, and Jake Howard, Jacob Walls, and Shai Berger for reviews. --- django/core/cache/backends/filebased.py | 10 +- django/core/files/storage/filesystem.py | 13 +- django/utils/_os.py | 58 ++++++++- docs/releases/4.2.29.txt | 15 +++ docs/releases/5.2.12.txt | 15 +++ docs/releases/6.0.3.txt | 15 +++ tests/utils_tests/test_os_utils.py | 166 +++++++++++++++++++++++- 7 files changed, 275 insertions(+), 17 deletions(-) diff --git a/django/core/cache/backends/filebased.py b/django/core/cache/backends/filebased.py index 862a8b57d93b..9f2ad48ac886 100644 --- a/django/core/cache/backends/filebased.py +++ b/django/core/cache/backends/filebased.py @@ -12,6 +12,7 @@ from django.core.cache.backends.base import DEFAULT_TIMEOUT, BaseCache from django.core.files import locks from django.core.files.move import file_move_safe +from django.utils._os import safe_makedirs class FileBasedCache(BaseCache): @@ -115,13 +116,10 @@ def _cull(self): self._delete(fname) def _createdir(self): - # Set the umask because os.makedirs() doesn't apply the "mode" argument + # Workaround because os.makedirs() doesn't apply the "mode" argument # to intermediate-level directories. - old_umask = os.umask(0o077) - try: - os.makedirs(self._dir, 0o700, exist_ok=True) - finally: - os.umask(old_umask) + # https://github.com/python/cpython/issues/86533 + safe_makedirs(self._dir, mode=0o700, exist_ok=True) def _key_to_file(self, key, version=None): """ diff --git a/django/core/files/storage/filesystem.py b/django/core/files/storage/filesystem.py index 9592bff00884..867f2e477c10 100644 --- a/django/core/files/storage/filesystem.py +++ b/django/core/files/storage/filesystem.py @@ -6,7 +6,7 @@ from django.core.files import File, locks from django.core.files.move import file_move_safe from django.core.signals import setting_changed -from django.utils._os import safe_join +from django.utils._os import safe_join, safe_makedirs from django.utils.deconstruct import deconstructible from django.utils.encoding import filepath_to_uri from django.utils.functional import cached_property @@ -72,15 +72,10 @@ def _save(self, name, content): directory = os.path.dirname(full_path) try: if self.directory_permissions_mode is not None: - # Set the umask because os.makedirs() doesn't apply the "mode" + # Workaround because os.makedirs() doesn't apply the "mode" # argument to intermediate-level directories. - old_umask = os.umask(0o777 & ~self.directory_permissions_mode) - try: - os.makedirs( - directory, self.directory_permissions_mode, exist_ok=True - ) - finally: - os.umask(old_umask) + # https://github.com/python/cpython/issues/86533 + safe_makedirs(directory, self.directory_permissions_mode, exist_ok=True) else: os.makedirs(directory, exist_ok=True) except FileExistsError: diff --git a/django/utils/_os.py b/django/utils/_os.py index 5cd8c566a8d7..f2969c5e0067 100644 --- a/django/utils/_os.py +++ b/django/utils/_os.py @@ -1,11 +1,67 @@ import os import tempfile -from os.path import abspath, dirname, join, normcase, sep +from os.path import abspath, curdir, dirname, join, normcase, sep from pathlib import Path from django.core.exceptions import SuspiciousFileOperation +# Copied verbatim (minus `os.path` fixes) from: +# https://github.com/python/cpython/pull/23901. +# Python versions >= PY315 may include this fix, so periodic checks are needed +# to remove this vendored copy of `makedirs` once solved upstream. +def makedirs(name, mode=0o777, exist_ok=False, *, parent_mode=None): + """makedirs(name [, mode=0o777][, exist_ok=False][, parent_mode=None]) + + Super-mkdir; create a leaf directory and all intermediate ones. Works like + mkdir, except that any intermediate path segment (not just the rightmost) + will be created if it does not exist. If the target directory already + exists, raise an OSError if exist_ok is False. Otherwise no exception is + raised. If parent_mode is not None, it will be used as the mode for any + newly-created, intermediate-level directories. Otherwise, intermediate + directories are created with the default permissions (respecting umask). + This is recursive. + + """ + head, tail = os.path.split(name) + if not tail: + head, tail = os.path.split(head) + if head and tail and not os.path.exists(head): + try: + if parent_mode is not None: + makedirs( + head, mode=parent_mode, exist_ok=exist_ok, parent_mode=parent_mode + ) + else: + makedirs(head, exist_ok=exist_ok) + except FileExistsError: + # Defeats race condition when another thread created the path + pass + cdir = curdir + if isinstance(tail, bytes): + cdir = bytes(curdir, "ASCII") + if tail == cdir: # xxx/newdir/. exists if xxx/newdir exists + return + try: + os.mkdir(name, mode) + # PY315: The call to `chmod()` is not in the CPython proposed code. + # Apply `chmod()` after `mkdir()` to enforce the exact requested + # permissions, since the kernel masks the mode argument with the + # process umask. This guarantees consistent directory permissions + # without mutating global umask state. + os.chmod(name, mode) + except OSError: + # Cannot rely on checking for EEXIST, since the operating system + # could give priority to other errors like EACCES or EROFS + if not exist_ok or not os.path.isdir(name): + raise + + +def safe_makedirs(name, mode=0o777, exist_ok=False): + """Create directories recursively with explicit `mode` on each level.""" + makedirs(name=name, mode=mode, exist_ok=exist_ok, parent_mode=mode) + + def safe_join(base, *paths): """ Join one or more path components to the base path component intelligently. diff --git a/docs/releases/4.2.29.txt b/docs/releases/4.2.29.txt index b780264929de..71170a576398 100644 --- a/docs/releases/4.2.29.txt +++ b/docs/releases/4.2.29.txt @@ -28,3 +28,18 @@ the previous behavior of ``URLField.to_python()``. This issue has severity "moderate" according to the :ref:`Django security policy `. + +CVE-2026-25674: Potential incorrect permissions on newly created file system objects +==================================================================================== + +Django's file-system storage and file-based cache backends used the process +``umask`` to control permissions when creating directories. In multi-threaded +environments, one thread's temporary umask change can affect other threads' +file and directory creation, resulting in file system objects being created +with unintended permissions. + +Django now applies the requested permissions via :func:`~os.chmod` after +:func:`~os.mkdir`, removing the dependency on the process-wide umask. + +This issue has severity "low" according to the :ref:`Django security policy +`. diff --git a/docs/releases/5.2.12.txt b/docs/releases/5.2.12.txt index be2c7bc80719..177bfd1cedec 100644 --- a/docs/releases/5.2.12.txt +++ b/docs/releases/5.2.12.txt @@ -30,6 +30,21 @@ the previous behavior of ``URLField.to_python()``. This issue has severity "moderate" according to the :ref:`Django security policy `. +CVE-2026-25674: Potential incorrect permissions on newly created file system objects +==================================================================================== + +Django's file-system storage and file-based cache backends used the process +``umask`` to control permissions when creating directories. In multi-threaded +environments, one thread's temporary umask change can affect other threads' +file and directory creation, resulting in file system objects being created +with unintended permissions. + +Django now applies the requested permissions via :func:`~os.chmod` after +:func:`~os.mkdir`, removing the dependency on the process-wide umask. + +This issue has severity "low" according to the :ref:`Django security policy +`. + Bugfixes ======== diff --git a/docs/releases/6.0.3.txt b/docs/releases/6.0.3.txt index 6750385c1eea..31f12b179232 100644 --- a/docs/releases/6.0.3.txt +++ b/docs/releases/6.0.3.txt @@ -29,6 +29,21 @@ the previous behavior of ``URLField.to_python()``. This issue has severity "moderate" according to the :ref:`Django security policy `. +CVE-2026-25674: Potential incorrect permissions on newly created file system objects +==================================================================================== + +Django's file-system storage and file-based cache backends used the process +``umask`` to control permissions when creating directories. In multi-threaded +environments, one thread's temporary umask change can affect other threads' +file and directory creation, resulting in file system objects being created +with unintended permissions. + +Django now applies the requested permissions via :func:`~os.chmod` after +:func:`~os.mkdir`, removing the dependency on the process-wide umask. + +This issue has severity "low" according to the :ref:`Django security policy +`. + Bugfixes ======== diff --git a/tests/utils_tests/test_os_utils.py b/tests/utils_tests/test_os_utils.py index 7204167688dd..290e418e64cd 100644 --- a/tests/utils_tests/test_os_utils.py +++ b/tests/utils_tests/test_os_utils.py @@ -1,9 +1,173 @@ import os +import stat +import sys +import tempfile import unittest from pathlib import Path from django.core.exceptions import SuspiciousFileOperation -from django.utils._os import safe_join, to_path +from django.utils._os import safe_join, safe_makedirs, to_path + + +class SafeMakeDirsTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.base = tmp.name + self.addCleanup(tmp.cleanup) + + def assertDirMode(self, path, expected): + self.assertIs(os.path.isdir(path), True) + if sys.platform == "win32": + # Windows partially supports chmod: dirs always end up with 0o777. + expected = 0o777 + + # These tests assume a typical process umask (0o022 or similar): they + # create directories with modes like 0o755 and 0o700, which don't have + # group/world write bits, so a typical umask doesn't change the final + # permissions. On unexpected failures, check whether umask has changed. + self.assertEqual(stat.S_IMODE(os.stat(path).st_mode), expected) + + def test_creates_directory_hierarchy_with_permissions(self): + path = os.path.join(self.base, "a", "b", "c") + safe_makedirs(path, mode=0o755) + + self.assertDirMode(os.path.join(self.base, "a"), 0o755) + self.assertDirMode(os.path.join(self.base, "a", "b"), 0o755) + self.assertDirMode(path, 0o755) + + def test_existing_directory_exist_ok(self): + path = os.path.join(self.base, "a") + os.mkdir(path, 0o700) + + safe_makedirs(path, mode=0o755, exist_ok=True) + + self.assertDirMode(path, 0o700) + + def test_existing_directory_exist_ok_false_raises(self): + path = os.path.join(self.base, "a") + os.mkdir(path) + + with self.assertRaises(FileExistsError): + safe_makedirs(path, mode=0o755, exist_ok=False) + + def test_existing_file_at_target_raises(self): + path = os.path.join(self.base, "a") + with open(path, "w") as f: + f.write("x") + + with self.assertRaises(FileExistsError): + safe_makedirs(path, mode=0o755, exist_ok=False) + + with self.assertRaises(FileExistsError): + safe_makedirs(path, mode=0o755, exist_ok=True) + + def test_file_in_intermediate_path_raises(self): + file_path = os.path.join(self.base, "a") + with open(file_path, "w") as f: + f.write("x") + + path = os.path.join(file_path, "b") + + expected = FileNotFoundError if sys.platform == "win32" else NotADirectoryError + + with self.assertRaises(expected): + safe_makedirs(path, mode=0o755, exist_ok=False) + + with self.assertRaises(expected): + safe_makedirs(path, mode=0o755, exist_ok=True) + + def test_existing_parent_preserves_permissions(self): + a = os.path.join(self.base, "a") + b = os.path.join(a, "b") + + os.mkdir(a, 0o700) + + safe_makedirs(b, mode=0o755, exist_ok=False) + + self.assertDirMode(a, 0o700) + self.assertDirMode(b, 0o755) + + c = os.path.join(a, "c") + safe_makedirs(c, mode=0o750, exist_ok=True) + + self.assertDirMode(a, 0o700) + self.assertDirMode(c, 0o750) + + def test_path_is_normalized(self): + path = os.path.join(self.base, "a", "b", "..", "c") + safe_makedirs(path, mode=0o755) + + self.assertDirMode(os.path.normpath(path), 0o755) + self.assertIs(os.path.isdir(os.path.join(self.base, "a", "c")), True) + + def test_permissions_unaffected_by_process_umask(self): + path = os.path.join(self.base, "a", "b", "c") + # `umask()` returns the current mask, so it'll be restored on cleanup. + self.addCleanup(os.umask, os.umask(0o077)) + + safe_makedirs(path, mode=0o755) + + self.assertDirMode(os.path.join(self.base, "a"), 0o755) + self.assertDirMode(os.path.join(self.base, "a", "b"), 0o755) + self.assertDirMode(path, 0o755) + + def test_permissions_correct_despite_concurrent_umask_change(self): + path = os.path.join(self.base, "a", "b", "c") + original_mkdir = os.mkdir + # `umask()` returns the current mask, so it'll be restored on cleanup. + self.addCleanup(os.umask, os.umask(0o000)) + + def mkdir_changing_umask(p, mode): + # Simulate a concurrent thread changing the process umask. + os.umask(0o077) + original_mkdir(p, mode) + + with unittest.mock.patch("os.mkdir", side_effect=mkdir_changing_umask): + safe_makedirs(path, mode=0o755) + + self.assertDirMode(os.path.join(self.base, "a"), 0o755) + self.assertDirMode(os.path.join(self.base, "a", "b"), 0o755) + self.assertDirMode(path, 0o755) + + def test_race_condition_exist_ok_false(self): + path = os.path.join(self.base, "a", "b") + + original_mkdir = os.mkdir + call_count = [0] + + # `safe_makedirs()` calls `os.mkdir()` for each level in the path. + # For path "a/b", mkdir is called twice: once for "a", once for "b". + def mkdir_with_race(p, mode): + call_count[0] += 1 + if call_count[0] == 1: + original_mkdir(p, mode) + else: + raise FileExistsError(f"Directory exists: '{p}'") + + with unittest.mock.patch("os.mkdir", side_effect=mkdir_with_race): + with self.assertRaises(FileExistsError): + safe_makedirs(path, mode=0o755, exist_ok=False) + + def test_race_condition_exist_ok_true(self): + path = os.path.join(self.base, "a", "b") + + original_mkdir = os.mkdir + call_count = [0] + + def mkdir_with_race(p, mode): + call_count[0] += 1 + if call_count[0] == 1: + original_mkdir(p, mode) + else: + # Simulate other thread creating the directory during the race. + # The directory needs to exist for `exist_ok=True` to succeed. + original_mkdir(p, mode) + raise FileExistsError(f"Directory exists: '{p}'") + + with unittest.mock.patch("os.mkdir", side_effect=mkdir_with_race): + safe_makedirs(path, mode=0o755, exist_ok=True) + + self.assertIs(os.path.isdir(path), True) class SafeJoinTests(unittest.TestCase): From 9b1745400b09293253158059e3a8fe407e5cc553 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:00:11 -0300 Subject: [PATCH 3/5] Added stub release notes for 6.0.4. --- docs/releases/6.0.4.txt | 12 ++++++++++++ docs/releases/index.txt | 1 + 2 files changed, 13 insertions(+) create mode 100644 docs/releases/6.0.4.txt diff --git a/docs/releases/6.0.4.txt b/docs/releases/6.0.4.txt new file mode 100644 index 000000000000..a3653a6e0e7b --- /dev/null +++ b/docs/releases/6.0.4.txt @@ -0,0 +1,12 @@ +========================== +Django 6.0.4 release notes +========================== + +*Expected April 7, 2026* + +Django 6.0.4 fixes several bugs in 6.0.3. + +Bugfixes +======== + +* ... diff --git a/docs/releases/index.txt b/docs/releases/index.txt index eae6e49c83dc..f2a2dcf72b39 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -32,6 +32,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 6.0.4 6.0.3 6.0.2 6.0.1 From 62ab467686845e2a12a2580997a81d4bf61edfc6 Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:03:22 -0300 Subject: [PATCH 4/5] Added CVE-2026-25673 and CVE-2026-25674 to security archive. --- docs/releases/security.txt | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/releases/security.txt b/docs/releases/security.txt index 93f4209154e1..acab6487a7e9 100644 --- a/docs/releases/security.txt +++ b/docs/releases/security.txt @@ -36,6 +36,29 @@ Issues under Django's security process All security issues have been handled under versions of Django's security process. These are listed below. +March 3, 2026 - :cve:`2026-25673` +--------------------------------- + +Potential denial-of-service vulnerability in ``URLField`` via Unicode +normalization on Windows. +`Full description +`__ + +* Django 6.0 :commit:`(patch) ` +* Django 5.2 :commit:`(patch) <4d3c184686626d224d9a87451410ecf802b41f7c>` +* Django 4.2 :commit:`(patch) ` + +March 3, 2026 - :cve:`2026-25674` +--------------------------------- + +Potential incorrect permissions on newly created file system objects. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <264d5c70ef3281a8869cb2ad45a3a52d5adbe790>` +* Django 5.2 :commit:`(patch) ` +* Django 4.2 :commit:`(patch) <54b50bf7d6dcbf02d4c01f853627cc9299d4934d>` + February 3, 2026 - :cve:`2025-13473` ------------------------------------ From 5b939808220fa879942303f4318276668d11b4d9 Mon Sep 17 00:00:00 2001 From: Lakshya Date: Thu, 12 Feb 2026 15:05:07 +0530 Subject: [PATCH 5/5] Fixed #20775 -- Clarified that SQL for text lookups varies per database. --- docs/ref/models/querysets.txt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index cc264a4e51da..9ec45653de2c 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -3275,6 +3275,8 @@ SQL equivalent: SELECT ... WHERE headline LIKE '%Lennon%'; +(The exact SQL syntax varies for each database engine.) + Note this will match the headline ``'Lennon honored today'`` but not ``'lennon honored today'``. @@ -3302,6 +3304,8 @@ SQL equivalent: SELECT ... WHERE headline ILIKE '%Lennon%'; +(The exact SQL syntax varies for each database engine.) + .. admonition:: SQLite users When using the SQLite backend and non-ASCII strings, bear in mind the @@ -3427,6 +3431,8 @@ SQL equivalent: SELECT ... WHERE headline LIKE 'Lennon%'; +(The exact SQL syntax varies for each database engine.) + SQLite doesn't support case-sensitive ``LIKE`` statements; ``startswith`` acts like ``istartswith`` for SQLite. @@ -3447,6 +3453,8 @@ SQL equivalent: SELECT ... WHERE headline ILIKE 'Lennon%'; +(The exact SQL syntax varies for each database engine.) + .. admonition:: SQLite users When using the SQLite backend and non-ASCII strings, bear in mind the @@ -3469,6 +3477,8 @@ SQL equivalent: SELECT ... WHERE headline LIKE '%Lennon'; +(The exact SQL syntax varies for each database engine.) + .. admonition:: SQLite users SQLite doesn't support case-sensitive ``LIKE`` statements; ``endswith`` @@ -3490,7 +3500,9 @@ SQL equivalent: .. code-block:: sql - SELECT ... WHERE headline ILIKE '%Lennon' + SELECT ... WHERE headline ILIKE '%Lennon'; + +(The exact SQL syntax varies for each database engine.) .. admonition:: SQLite users