diff --git a/CVE-2024-38875.patch b/CVE-2024-38875.patch new file mode 100644 index 0000000000000000000000000000000000000000..d52a70b4019c5d97e697126182d44c4e632008b8 --- /dev/null +++ b/CVE-2024-38875.patch @@ -0,0 +1,154 @@ +From 8623260fb0949d368376a128bee2189ec0a67ae5 Mon Sep 17 00:00:00 2001 +From: nkrapp +Date: Mon, 22 Jul 2024 09:43:08 +0200 +Subject: [PATCH] [PATCH] [4.2.x] Fixed CVE-2024-38875 -- Mitigated potential + DoS in urlize and urlizetrunc template filters. + +--- + django/utils/html.py | 72 +++++++++++++++++++++++++++++----- + tests/utils_tests/test_html.py | 21 ++++++---- + 2 files changed, 75 insertions(+), 17 deletions(-) + +diff --git a/django/utils/html.py b/django/utils/html.py +index 7a33d5f68d..1dbe39ccd1 100644 +--- a/django/utils/html.py ++++ b/django/utils/html.py +@@ -1,5 +1,6 @@ + """HTML utilities suitable for global use.""" + ++import html + import json + import re + from html.parser import HTMLParser +@@ -235,6 +235,16 @@ def smart_urlquote(url): + return urlunsplit((scheme, netloc, path, query, fragment)) + + ++class CountsDict(dict): ++ def __init__(self, *args, word, **kwargs): ++ super().__init__(*args, *kwargs) ++ self.word = word ++ ++ def __missing__(self, key): ++ self[key] = self.word.count(key) ++ return self[key] ++ ++ + @keep_lazy_text + def urlize(text, trim_url_limit=None, nofollow=False, autoescape=False): + """ +@@ -259,6 +269,15 @@ def urlize(text, trim_url_limit=None, nofollow=False, autoescape=False): + return x + return '%s…' % x[:max(0, limit - 1)] + ++ def wrapping_punctuation_openings(): ++ return "".join(dict(WRAPPING_PUNCTUATION).keys()) ++ ++ def trailing_punctuation_chars_no_semicolon(): ++ return TRAILING_PUNCTUATION_CHARS.replace(";", "") ++ ++ def trailing_punctuation_chars_has_semicolon(): ++ return ";" in TRAILING_PUNCTUATION_CHARS ++ + def unescape(text): + """ + If input URL is HTML-escaped, unescape it so that it can be safely fed +@@ -273,21 +292,53 @@ def urlize(text, trim_url_limit=None, nofollow=False, autoescape=False): + Trim trailing and wrapping punctuation from `middle`. Return the items + of the new state. + """ ++ # Strip all opening wrapping punctuation. ++ middle = word.lstrip(wrapping_punctuation_openings()) ++ lead = word[: len(word) - len(middle)] ++ trail = "" ++ + # Continue trimming until middle remains unchanged. + trimmed_something = True +- while trimmed_something: ++ counts = CountsDict(word=middle) ++ while trimmed_something and middle: + trimmed_something = False + # Trim wrapping punctuation. + for opening, closing in WRAPPING_PUNCTUATION: +- if middle.startswith(opening): +- middle = middle[len(opening):] +- lead += opening +- trimmed_something = True +- # Keep parentheses at the end only if they're balanced. +- if (middle.endswith(closing) and +- middle.count(closing) == middle.count(opening) + 1): +- middle = middle[:-len(closing)] +- trail = closing + trail ++ if counts[opening] < counts[closing]: ++ rstripped = middle.rstrip(closing) ++ if rstripped != middle: ++ strip = counts[closing] - counts[opening] ++ trail = middle[-strip:] ++ middle = middle[:-strip] ++ trimmed_something = True ++ counts[closing] -= strip ++ ++ rstripped = middle.rstrip(trailing_punctuation_chars_no_semicolon()) ++ if rstripped != middle: ++ trail = middle[len(rstripped) :] + trail ++ middle = rstripped ++ trimmed_something = True ++ ++ if trailing_punctuation_chars_has_semicolon() and middle.endswith(";"): ++ # Only strip if not part of an HTML entity. ++ amp = middle.rfind("&") ++ if amp == -1: ++ can_strip = True ++ else: ++ potential_entity = middle[amp:] ++ escaped = html.unescape(potential_entity) ++ can_strip = (escaped == potential_entity) or escaped.endswith(";") ++ ++ if can_strip: ++ rstripped = middle.rstrip(";") ++ amount_stripped = len(middle) - len(rstripped) ++ if amp > -1 and amount_stripped > 1: ++ # Leave a trailing semicolon as might be an entity. ++ trail = middle[len(rstripped) + 1 :] + trail ++ middle = rstripped + ";" ++ else: ++ trail = middle[len(rstripped) :] + trail ++ middle = rstripped + trimmed_something = True + # Trim trailing punctuation (after trimming wrapping punctuation, + # as encoded entities contain ';'). Unescape entites to avoid +diff --git a/tests/utils_tests/test_html.py b/tests/utils_tests/test_html.py +index 5cc2d9b95d..dc89009b63 100644 +--- a/tests/utils_tests/test_html.py ++++ b/tests/utils_tests/test_html.py +@@ -260,13 +260,20 @@ class TestUtilsHtml(SimpleTestCase): + + def test_urlize_unchanged_inputs(self): + tests = ( +- ('a' + '@a' * 50000) + 'a', # simple_email_re catastrophic test +- ('a' + '.' * 1000000) + 'a', # trailing_punctuation catastrophic test +- 'foo@', +- '@foo.com', +- 'foo@.example.com', +- 'foo@localhost', +- 'foo@localhost.', ++ ("a" + "@a" * 50000) + "a", # simple_email_re catastrophic test ++ ("a" + "." * 1000000) + "a", # trailing_punctuation catastrophic test ++ "foo@", ++ "@foo.com", ++ "foo@.example.com", ++ "foo@localhost", ++ "foo@localhost.", ++ # trim_punctuation catastrophic tests ++ "(" * 100_000 + ":" + ")" * 100_000, ++ "(" * 100_000 + "&:" + ")" * 100_000, ++ "([" * 100_000 + ":" + "])" * 100_000, ++ "[(" * 100_000 + ":" + ")]" * 100_000, ++ "([[" * 100_000 + ":" + "]])" * 100_000, ++ "&:" + ";" * 100_000, + ) + for value in tests: + with self.subTest(value=value): +-- +2.45.2 + diff --git a/CVE-2024-39329.patch b/CVE-2024-39329.patch new file mode 100644 index 0000000000000000000000000000000000000000..9534e4f8d0978bd901ad35bade51be535191f940 --- /dev/null +++ b/CVE-2024-39329.patch @@ -0,0 +1,77 @@ +From c676b05c98df58252509dd5ad16b959c351ac770 Mon Sep 17 00:00:00 2001 +From: nkrapp +Date: Fri, 26 Jul 2024 14:01:36 +0200 +Subject: [PATCH] Fixed CVE-2024-39329 -- Standarized timing of + verify_password() when checking unusuable passwords. + +Refs #20760. + +Thanks Michael Manfre for the fix and to Adam Johnson for the review. +--- + django/contrib/auth/hashers.py | 10 ++++++++-- + tests/auth_tests/test_hashers.py | 22 ++++++++++++++++++++++ + 2 files changed, 30 insertions(+), 2 deletions(-) + +diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py +index 1e8d7547fc..af55e57389 100644 +--- a/django/contrib/auth/hashers.py ++++ b/django/contrib/auth/hashers.py +@@ -36,14 +36,20 @@ def check_password(password, encoded, setter=None, preferred='default'): + If setter is specified, it'll be called when you need to + regenerate the password. + """ +- if password is None or not is_password_usable(encoded): +- return False ++ fake_runtime = password is None or not is_password_usable(encoded) + + preferred = get_hasher(preferred) + try: + hasher = identify_hasher(encoded) + except ValueError: + # encoded is gibberish or uses a hasher that's no longer installed. ++ fake_runtime = True ++ ++ if fake_runtime: ++ # Run the default password hasher once to reduce the timing difference ++ # between an existing user with an unusable password and a nonexistent ++ # user or missing hasher (similar to #20760). ++ make_password(get_random_string(UNUSABLE_PASSWORD_SUFFIX_LENGTH)) + return False + + hasher_changed = hasher.algorithm != preferred.algorithm +diff --git a/tests/auth_tests/test_hashers.py b/tests/auth_tests/test_hashers.py +index ee6441b237..170bd07c4b 100644 +--- a/tests/auth_tests/test_hashers.py ++++ b/tests/auth_tests/test_hashers.py +@@ -433,6 +433,28 @@ class TestUtilsHashPass(SimpleTestCase): + check_password('wrong_password', encoded) + self.assertEqual(hasher.harden_runtime.call_count, 1) + ++ def test_check_password_calls_make_password_to_fake_runtime(self): ++ hasher = get_hasher("default") ++ cases = [ ++ (None, None, None), # no plain text password provided ++ ("foo", make_password(password=None), None), # unusable encoded ++ ("letmein", make_password(password="letmein"), ValueError), # valid encoded ++ ] ++ for password, encoded, hasher_side_effect in cases: ++ with self.subTest(encoded=encoded): ++ with mock.patch("django.contrib.auth.hashers.identify_hasher", side_effect=hasher_side_effect) as mock_identify_hasher: ++ with mock.patch("django.contrib.auth.hashers.make_password") as mock_make_password: ++ with mock.patch("django.contrib.auth.hashers.get_random_string", side_effect=lambda size: "x" * size): ++ with mock.patch.object(hasher, "verify"): ++ # Ensure make_password is called to standardize timing. ++ check_password(password, encoded) ++ self.assertEqual(hasher.verify.call_count, 0) ++ self.assertEqual(mock_identify_hasher.mock_calls, [mock.call(encoded)]) ++ self.assertEqual( ++ mock_make_password.mock_calls, ++ [mock.call("x" * UNUSABLE_PASSWORD_SUFFIX_LENGTH)], ++ ) ++ + + class BasePasswordHasherTests(SimpleTestCase): + not_implemented_msg = 'subclasses of BasePasswordHasher must provide %s() method' +-- +2.45.2 + diff --git a/CVE-2024-39330.patch b/CVE-2024-39330.patch new file mode 100644 index 0000000000000000000000000000000000000000..9202f8d73aa575d9cc12b20e6008568bbd77d11f --- /dev/null +++ b/CVE-2024-39330.patch @@ -0,0 +1,147 @@ +From 72af4b325aa9ffd96b18ef68d26ec2260e982c2a Mon Sep 17 00:00:00 2001 +From: nkrapp +Date: Wed, 24 Jul 2024 17:08:23 +0200 +Subject: [PATCH] Fixed CVE-2024-39330 -- Added extra file name validation in + Storage's save method. + +Thanks to Josh Schneier for the report, and to Carlton Gibson and Sarah +Boyce for the reviews. +--- + django/core/files/storage.py | 11 ++++++ + django/core/files/utils.py | 7 ++-- + tests/file_storage/test_base.py | 64 +++++++++++++++++++++++++++++++++ + tests/file_storage/tests.py | 6 ---- + 4 files changed, 78 insertions(+), 10 deletions(-) + create mode 100644 tests/file_storage/test_base.py + +diff --git a/django/core/files/storage.py b/django/core/files/storage.py +index ea5bbc82d0..8c633ec040 100644 +--- a/django/core/files/storage.py ++++ b/django/core/files/storage.py +@@ -50,7 +50,18 @@ class Storage: + if not hasattr(content, 'chunks'): + content = File(content, name) + ++ # Ensure that the name is valid, before and after having the storage ++ # system potentially modifying the name. This duplicates the check made ++ # inside `get_available_name` but it's necessary for those cases where ++ # `get_available_name` is overriden and validation is lost. ++ validate_file_name(name, allow_relative_path=True) ++ ++ # Potentially find a different name depending on storage constraints. + name = self.get_available_name(name, max_length=max_length) ++ # Validate the (potentially) new name. ++ validate_file_name(name, allow_relative_path=True) ++ ++ # The save operation should return the actual name of the file saved. + name = self._save(name, content) + # Ensure that the name returned from the storage system is still valid. + validate_file_name(name, allow_relative_path=True) +diff --git a/django/core/files/utils.py b/django/core/files/utils.py +index f28cea1077..a1fea44ded 100644 +--- a/django/core/files/utils.py ++++ b/django/core/files/utils.py +@@ -10,10 +10,9 @@ def validate_file_name(name, allow_relative_path=False): + raise SuspiciousFileOperation("Could not derive file name from '%s'" % name) + + if allow_relative_path: +- # Use PurePosixPath() because this branch is checked only in +- # FileField.generate_filename() where all file paths are expected to be +- # Unix style (with forward slashes). +- path = pathlib.PurePosixPath(name) ++ # Ensure that name can be treated as a pure posix path, i.e. Unix ++ # style (with forward slashes). ++ path = pathlib.PurePosixPath(str(name).replace("\\", "/")) + if path.is_absolute() or '..' in path.parts: + raise SuspiciousFileOperation( + "Detected path traversal attempt in '%s'" % name +diff --git a/tests/file_storage/test_base.py b/tests/file_storage/test_base.py +new file mode 100644 +index 0000000000..7a0838f7a5 +--- /dev/null ++++ b/tests/file_storage/test_base.py +@@ -0,0 +1,64 @@ ++import os ++from unittest import mock ++ ++from django.core.exceptions import SuspiciousFileOperation ++from django.core.files.storage import Storage ++from django.test import SimpleTestCase ++ ++ ++class CustomStorage(Storage): ++ """Simple Storage subclass implementing the bare minimum for testing.""" ++ ++ def exists(self, name): ++ return False ++ ++ def _save(self, name): ++ return name ++ ++ ++class StorageValidateFileNameTests(SimpleTestCase): ++ invalid_file_names = [ ++ os.path.join("path", "to", os.pardir, "test.file"), ++ os.path.join(os.path.sep, "path", "to", "test.file"), ++ ] ++ error_msg = "Detected path traversal attempt in '%s'" ++ ++ def test_validate_before_get_available_name(self): ++ s = CustomStorage() ++ # The initial name passed to `save` is not valid nor safe, fail early. ++ for name in self.invalid_file_names: ++ with self.subTest(name=name): ++ with mock.patch.object(s, "get_available_name") as mock_get_available_names: ++ with mock.patch.object(s, "_save") as mock_internal_save: ++ with self.assertRaisesMessage( ++ SuspiciousFileOperation, self.error_msg % name ++ ): ++ s.save(name, content="irrelevant") ++ self.assertEqual(mock_get_available_names.mock_calls, []) ++ self.assertEqual(mock_internal_save.mock_calls, []) ++ ++ def test_validate_after_get_available_name(self): ++ s = CustomStorage() ++ # The initial name passed to `save` is valid and safe, but the returned ++ # name from `get_available_name` is not. ++ for name in self.invalid_file_names: ++ with self.subTest(name=name): ++ with mock.patch.object(s, "get_available_name", return_value=name): ++ with mock.patch.object(s, "_save") as mock_internal_save: ++ with self.assertRaisesMessage( ++ SuspiciousFileOperation, self.error_msg % name ++ ): ++ s.save("valid-file-name.txt", content="irrelevant") ++ self.assertEqual(mock_internal_save.mock_calls, []) ++ ++ def test_validate_after_internal_save(self): ++ s = CustomStorage() ++ # The initial name passed to `save` is valid and safe, but the result ++ # from `_save` is not (this is achieved by monkeypatching _save). ++ for name in self.invalid_file_names: ++ with self.subTest(name=name): ++ with mock.patch.object(s, "_save", return_value=name): ++ with self.assertRaisesMessage( ++ SuspiciousFileOperation, self.error_msg % name ++ ): ++ s.save("valid-file-name.txt", content="irrelevant") +diff --git a/tests/file_storage/tests.py b/tests/file_storage/tests.py +index 4c6f6920ed..0e692644b7 100644 +--- a/tests/file_storage/tests.py ++++ b/tests/file_storage/tests.py +@@ -291,12 +291,6 @@ class FileStorageTests(SimpleTestCase): + + self.storage.delete('path/to/test.file') + +- def test_file_save_abs_path(self): +- test_name = 'path/to/test.file' +- f = ContentFile('file saved with path') +- f_name = self.storage.save(os.path.join(self.temp_dir, test_name), f) +- self.assertEqual(f_name, test_name) +- + def test_save_doesnt_close(self): + with TemporaryUploadedFile('test', 'text/plain', 1, 'utf8') as file: + file.write(b'1') +-- +2.45.2 + diff --git a/CVE-2024-39614.patch b/CVE-2024-39614.patch new file mode 100644 index 0000000000000000000000000000000000000000..09129745fb09725457c3cbbd4b537814d83c302d --- /dev/null +++ b/CVE-2024-39614.patch @@ -0,0 +1,195 @@ +From 2f128b1865bc43f6cf3583b1255bf1bd8be29e57 Mon Sep 17 00:00:00 2001 +From: nkrapp +Date: Mon, 22 Jul 2024 11:23:29 +0200 +Subject: [PATCH] Fixed CVE-2024-39614 -- Mitigated potential DoS in + get_supported_language_variant(). + +Language codes are now parsed with a maximum length limit of 500 chars. + +Thanks to MProgrammer for the report. +--- + django/utils/translation/trans_real.py | 29 ++++++++++--- + docs/ref/utils.txt | 25 +++++++++++ + tests/i18n/tests.py | 59 ++++++++++++++++++++++++++ + 3 files changed, 107 insertions(+), 6 deletions(-) + +diff --git a/django/utils/translation/trans_real.py b/django/utils/translation/trans_real.py +index ecd701f3d8..0a237a5afc 100644 +--- a/django/utils/translation/trans_real.py ++++ b/django/utils/translation/trans_real.py +@@ -30,9 +30,10 @@ _default = None + CONTEXT_SEPARATOR = "\x04" + + # Maximum number of characters that will be parsed from the Accept-Language +-# header to prevent possible denial of service or memory exhaustion attacks. +-# About 10x longer than the longest value shown on MDN’s Accept-Language page. +-ACCEPT_LANGUAGE_HEADER_MAX_LENGTH = 500 ++# header or cookie to prevent possible denial of service or memory exhaustion ++# attacks. About 10x longer than the longest value shown on MDN’s ++# Accept-Language page. ++LANGUAGE_CODE_MAX_LENGTH = 500 + + # Format of Accept-Language header values. From RFC 2616, section 14.4 and 3.9 + # and RFC 3066, section 2.1 +@@ -473,12 +474,28 @@ def get_supported_language_variant(lang_code, strict=False): + If `strict` is False (the default), look for a country-specific variant + when neither the language code nor its generic variant is found. + ++ The language code is truncated to a maximum length to avoid potential ++ denial of service attacks. ++ + lru_cache should have a maxsize to prevent from memory exhaustion attacks, + as the provided language codes are taken from the HTTP request. See also + . + """ + if lang_code: +- # If 'fr-ca' is not supported, try special fallback or language-only 'fr'. ++ # Truncate the language code to a maximum length to avoid potential ++ # denial of service attacks. ++ if len(lang_code) > LANGUAGE_CODE_MAX_LENGTH: ++ index = lang_code.rfind("-", 0, LANGUAGE_CODE_MAX_LENGTH) ++ if ( ++ not strict ++ and index > 0 ++ ): ++ # There is a generic variant under the maximum length accepted length. ++ lang_code = lang_code[:index] ++ else: ++ raise ValueError("'lang_code' exceeds the maximum accepted length") ++ # If 'zh-hant-tw' is not supported, try special fallback or subsequent ++ # language codes i.e. 'zh-hant' and 'zh'. + possible_lang_codes = [lang_code] + try: + possible_lang_codes.extend(LANG_INFO[lang_code]['fallback']) +@@ -599,13 +616,13 @@ def parse_accept_lang_header(lang_string): + functools.lru_cache() to avoid repetitive parsing of common header values. + """ + # If the header value doesn't exceed the maximum allowed length, parse it. +- if len(lang_string) <= ACCEPT_LANGUAGE_HEADER_MAX_LENGTH: ++ if len(lang_string) <= LANGUAGE_CODE_MAX_LENGTH: + return _parse_accept_lang_header(lang_string) + + # If there is at least one comma in the value, parse up to the last comma + # before the max length, skipping any truncated parts at the end of the + # header value. +- index = lang_string.rfind(",", 0, ACCEPT_LANGUAGE_HEADER_MAX_LENGTH) ++ index = lang_string.rfind(",", 0, LANGUAGE_CODE_MAX_LENGTH) + if index > 0: + return _parse_accept_lang_header(lang_string[:index]) + +diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt +index 390f167ce2..d0a8e8c1f3 100644 +--- a/docs/ref/utils.txt ++++ b/docs/ref/utils.txt +@@ -1150,6 +1150,31 @@ functions without the ``u``. + + Raises :exc:`LookupError` if nothing is found. + ++.. function:: get_supported_language_variant(lang_code, strict=False) ++ ++ Returns ``lang_code`` if it's in the :setting:`LANGUAGES` setting, possibly ++ selecting a more generic variant. For example, ``'es'`` is returned if ++ ``lang_code`` is ``'es-ar'`` and ``'es'`` is in :setting:`LANGUAGES` but ++ ``'es-ar'`` isn't. ++ ++ ``lang_code`` has a maximum accepted length of 500 characters. A ++ :exc:`ValueError` is raised if ``lang_code`` exceeds this limit and ++ ``strict`` is ``True``, or if there is no generic variant and ``strict`` ++ is ``False``. ++ ++ If ``strict`` is ``False`` (the default), a country-specific variant may ++ be returned when neither the language code nor its generic variant is found. ++ For example, if only ``'es-co'`` is in :setting:`LANGUAGES`, that's ++ returned for ``lang_code``\s like ``'es'`` and ``'es-ar'``. Those matches ++ aren't returned if ``strict=True``. ++ ++ Raises :exc:`LookupError` if nothing is found. ++ ++ .. versionchanged:: 4.2.14 ++ ++ In older versions, ``lang_code`` values over 500 characters were ++ processed without raising a :exc:`ValueError`. ++ + .. function:: to_locale(language) + + Turns a language name (en-us) into a locale name (en_US). +diff --git a/tests/i18n/tests.py b/tests/i18n/tests.py +index 6efc3a5ae3..3087e5b6a6 100644 +--- a/tests/i18n/tests.py ++++ b/tests/i18n/tests.py +@@ -39,6 +39,7 @@ from django.utils.translation import ( + from django.utils.translation.reloader import ( + translation_file_changed, watch_for_translation_changes, + ) ++from django.utils.translation.trans_real import LANGUAGE_CODE_MAX_LENGTH + + from .forms import CompanyForm, I18nForm, SelectDateForm + from .models import Company, TestModel +@@ -1434,6 +1435,64 @@ class MiscTests(SimpleTestCase): + r.COOKIES = {settings.LANGUAGE_COOKIE_NAME: 'zh-hans'} + r.META = {'HTTP_ACCEPT_LANGUAGE': 'de'} + self.assertEqual(g(r), 'zh-hans') ++ ++ @override_settings( ++ USE_I18N=True, ++ LANGUAGES=[ ++ ("en", "English"), ++ ("ar-dz", "Algerian Arabic"), ++ ("de", "German"), ++ ("de-at", "Austrian German"), ++ ("pt-BR", "Portuguese (Brazil)"), ++ ], ++ ) ++ def test_get_supported_language_variant_real(self): ++ g = trans_real.get_supported_language_variant ++ self.assertEqual(g("en"), "en") ++ self.assertEqual(g("en-gb"), "en") ++ self.assertEqual(g("de"), "de") ++ self.assertEqual(g("de-at"), "de-at") ++ self.assertEqual(g("de-ch"), "de") ++ self.assertEqual(g("pt-br"), "pt-br") ++ self.assertEqual(g("pt-BR"), "pt-BR") ++ self.assertEqual(g("pt"), "pt-br") ++ self.assertEqual(g("pt-pt"), "pt-br") ++ self.assertEqual(g("ar-dz"), "ar-dz") ++ self.assertEqual(g("ar-DZ"), "ar-DZ") ++ with self.assertRaises(LookupError): ++ g("pt", strict=True) ++ with self.assertRaises(LookupError): ++ g("pt-pt", strict=True) ++ with self.assertRaises(LookupError): ++ g("xyz") ++ with self.assertRaises(LookupError): ++ g("xy-zz") ++ msg = "'lang_code' exceeds the maximum accepted length" ++ with self.assertRaises(LookupError): ++ g("x" * LANGUAGE_CODE_MAX_LENGTH) ++ with self.assertRaisesMessage(ValueError, msg): ++ g("x" * (LANGUAGE_CODE_MAX_LENGTH + 1)) ++ # 167 * 3 = 501 which is LANGUAGE_CODE_MAX_LENGTH + 1. ++ self.assertEqual(g("en-" * 167), "en") ++ with self.assertRaisesMessage(ValueError, msg): ++ g("en-" * 167, strict=True) ++ self.assertEqual(g("en-" * 30000), "en") # catastrophic test ++ ++ def test_get_supported_language_variant_null(self): ++ g = trans_null.get_supported_language_variant ++ self.assertEqual(g(settings.LANGUAGE_CODE), settings.LANGUAGE_CODE) ++ with self.assertRaises(LookupError): ++ g("pt") ++ with self.assertRaises(LookupError): ++ g("de") ++ with self.assertRaises(LookupError): ++ g("de-at") ++ with self.assertRaises(LookupError): ++ g("de", strict=True) ++ with self.assertRaises(LookupError): ++ g("de-at", strict=True) ++ with self.assertRaises(LookupError): ++ g("xyz") + + @override_settings( + USE_I18N=True, +-- +2.45.2 + diff --git a/CVE-2024-41989.patch b/CVE-2024-41989.patch new file mode 100644 index 0000000000000000000000000000000000000000..057eed7f857606b12bf2891dcd39fa8cc622fe97 --- /dev/null +++ b/CVE-2024-41989.patch @@ -0,0 +1,76 @@ +From 0521744d21a7854e849336af1e3a3aad44cee017 Mon Sep 17 00:00:00 2001 +From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> +Date: Fri, 12 Jul 2024 11:38:34 +0200 +Subject: [PATCH 1/4] [4.2.x] Fixed CVE-2024-41989 -- Prevented excessive + memory consumption in floatformat. +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Thanks Elias Myllymäki for the report. + +Co-authored-by: Shai Berger +--- + django/template/defaultfilters.py | 13 +++++++++++++ + .../filter_tests/test_floatformat.py | 17 +++++++++++++++++ + 3 files changed, 39 insertions(+) + +Index: Django-2.2.28/django/template/defaultfilters.py +=================================================================== +--- Django-2.2.28.orig/django/template/defaultfilters.py ++++ Django-2.2.28/django/template/defaultfilters.py +@@ -135,6 +135,19 @@ def floatformat(text, arg=-1): + except ValueError: + return input_val + ++ _, digits, exponent = d.as_tuple() ++ try: ++ number_of_digits_and_exponent_sum = len(digits) + abs(exponent) ++ except TypeError: ++ # Exponent values can be "F", "n", "N". ++ number_of_digits_and_exponent_sum = 0 ++ ++ # Values with more than 200 digits, or with a large exponent, are returned "as is" ++ # to avoid high memory consumption and potential denial-of-service attacks. ++ # The cut-off of 200 is consistent with django.utils.numberformat.floatformat(). ++ if number_of_digits_and_exponent_sum > 200: ++ return input_val ++ + try: + m = int(d) - d + except (ValueError, OverflowError, InvalidOperation): +Index: Django-2.2.28/tests/template_tests/filter_tests/test_floatformat.py +=================================================================== +--- Django-2.2.28.orig/tests/template_tests/filter_tests/test_floatformat.py ++++ Django-2.2.28/tests/template_tests/filter_tests/test_floatformat.py +@@ -55,6 +55,7 @@ class FunctionTests(SimpleTestCase): + self.assertEqual(floatformat(1.5e-15, 20), '0.00000000000000150000') + self.assertEqual(floatformat(1.5e-15, -20), '0.00000000000000150000') + self.assertEqual(floatformat(1.00000000000000015, 16), '1.0000000000000002') ++ self.assertEqual(floatformat("1e199"), "1" + "0" * 199) + + def test_zero_values(self): + self.assertEqual(floatformat(0, 6), '0.000000') +@@ -68,6 +69,22 @@ class FunctionTests(SimpleTestCase): + self.assertEqual(floatformat(pos_inf), 'inf') + self.assertEqual(floatformat(neg_inf), '-inf') + self.assertEqual(floatformat(pos_inf / pos_inf), 'nan') ++ self.assertEqual(floatformat("inf"), "inf") ++ self.assertEqual(floatformat("NaN"), "NaN") ++ ++ def test_too_many_digits_to_render(self): ++ cases = [ ++ "1e200", ++ "1E200", ++ "1E10000000000000000", ++ "-1E10000000000000000", ++ "1e10000000000000000", ++ "-1e10000000000000000", ++ "1" + "0" * 1_000_000, ++ ] ++ for value in cases: ++ with self.subTest(value=value): ++ self.assertEqual(floatformat(value), value) + + def test_float_dunder_method(self): + class FloatWrapper: diff --git a/CVE-2024-41990.patch b/CVE-2024-41990.patch new file mode 100644 index 0000000000000000000000000000000000000000..07623d0d89afdd908608defacbc5f4a0fe4a24ff --- /dev/null +++ b/CVE-2024-41990.patch @@ -0,0 +1,61 @@ +From 729d7934e34ff91f262f3e7089e32cab701b09ca Mon Sep 17 00:00:00 2001 +From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> +Date: Thu, 18 Jul 2024 13:19:34 +0200 +Subject: [PATCH 2/4] [4.2.x] Fixed CVE-2024-41990 -- Mitigated potential DoS + in urlize and urlizetrunc template filters. + +Thanks to MProgrammer for the report. +--- + django/utils/html.py | 18 ++++++++---------- + tests/utils_tests/test_html.py | 2 ++ + 3 files changed, 17 insertions(+), 10 deletions(-) + +Index: Django-2.2.28/django/utils/html.py +=================================================================== +--- Django-2.2.28.orig/django/utils/html.py ++++ Django-2.2.28/django/utils/html.py +@@ -314,7 +314,11 @@ def urlize(text, trim_url_limit=None, no + trimmed_something = True + counts[closing] -= strip + +- rstripped = middle.rstrip(trailing_punctuation_chars_no_semicolon()) ++ amp = middle.rfind("&") ++ if amp == -1: ++ rstripped = middle.rstrip(TRAILING_PUNCTUATION_CHARS) ++ else: ++ rstripped = middle.rstrip(trailing_punctuation_chars_no_semicolon()) + if rstripped != middle: + trail = middle[len(rstripped) :] + trail + middle = rstripped +@@ -322,15 +326,9 @@ def urlize(text, trim_url_limit=None, no + + if trailing_punctuation_chars_has_semicolon() and middle.endswith(";"): + # Only strip if not part of an HTML entity. +- amp = middle.rfind("&") +- if amp == -1: +- can_strip = True +- else: +- potential_entity = middle[amp:] +- escaped = html.unescape(potential_entity) +- can_strip = (escaped == potential_entity) or escaped.endswith(";") +- +- if can_strip: ++ potential_entity = middle[amp:] ++ escaped = html.unescape(potential_entity) ++ if escaped == potential_entity or escaped.endswith(";"): + rstripped = middle.rstrip(";") + amount_stripped = len(middle) - len(rstripped) + if amp > -1 and amount_stripped > 1: +Index: Django-2.2.28/tests/utils_tests/test_html.py +=================================================================== +--- Django-2.2.28.orig/tests/utils_tests/test_html.py ++++ Django-2.2.28/tests/utils_tests/test_html.py +@@ -274,6 +274,8 @@ class TestUtilsHtml(SimpleTestCase): + "[(" * 100_000 + ":" + ")]" * 100_000, + "([[" * 100_000 + ":" + "]])" * 100_000, + "&:" + ";" * 100_000, ++ "&.;" * 100_000, ++ ".;" * 100_000, + ) + for value in tests: + with self.subTest(value=value): diff --git a/CVE-2024-41991.patch b/CVE-2024-41991.patch new file mode 100644 index 0000000000000000000000000000000000000000..46f89ab7d0d6eeaed5a9ef0ba0a1f6615eef0b3e --- /dev/null +++ b/CVE-2024-41991.patch @@ -0,0 +1,98 @@ +From 772a73f70c3d249c99c23012849e66276b7b0715 Mon Sep 17 00:00:00 2001 +From: Mariusz Felisiak +Date: Wed, 10 Jul 2024 20:30:12 +0200 +Subject: [PATCH 3/4] [4.2.x] Fixed CVE-2024-41991 -- Prevented potential ReDoS + in django.utils.html.urlize() and AdminURLFieldWidget. + +Thanks Seokchan Yoon for the report. + +Co-authored-by: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> +--- + django/contrib/admin/widgets.py | 2 +- + django/utils/html.py | 10 ++++++++-- + tests/admin_widgets/tests.py | 7 ++++++- + tests/utils_tests/test_html.py | 13 +++++++++++++ + 5 files changed, 35 insertions(+), 4 deletions(-) + +Index: Django-2.2.28/django/contrib/admin/widgets.py +=================================================================== +--- Django-2.2.28.orig/django/contrib/admin/widgets.py ++++ Django-2.2.28/django/contrib/admin/widgets.py +@@ -344,7 +344,7 @@ class AdminURLFieldWidget(forms.URLInput + context = super().get_context(name, value, attrs) + context['current_label'] = _('Currently:') + context['change_label'] = _('Change:') +- context['widget']['href'] = smart_urlquote(context['widget']['value']) if value else '' ++ context['widget']['href'] = smart_urlquote(context['widget']['value']) if url_valid else '' + context['url_valid'] = url_valid + return context + +Index: Django-2.2.28/django/utils/html.py +=================================================================== +--- Django-2.2.28.orig/django/utils/html.py ++++ Django-2.2.28/django/utils/html.py +@@ -33,6 +33,8 @@ _html_escapes = { + ord("'"): ''', + } + ++MAX_URL_LENGTH = 2048 ++ + + @keep_lazy(str, SafeText) + def escape(text): +@@ -360,6 +362,10 @@ def urlize(text, trim_url_limit=None, no + except ValueError: + # value contains more than one @. + return False ++ # Max length for domain name labels is 63 characters per RFC 1034. ++ # Helps to avoid ReDoS vectors in the domain part. ++ if len(p2) > 63: ++ return False + # Dot must be in p2 (e.g. example.com) + if '.' not in p2 or p2.startswith('.'): + return False +@@ -378,9 +384,9 @@ def urlize(text, trim_url_limit=None, no + # Make URL we want to point to. + url = None + nofollow_attr = ' rel="nofollow"' if nofollow else '' +- if simple_url_re.match(middle): ++ if len(middle) <= MAX_URL_LENGTH and simple_url_re.match(middle): + url = smart_urlquote(unescape(middle)) +- elif simple_url_2_re.match(middle): ++ elif len(middle) <= MAX_URL_LENGTH and simple_url_2_re.match(middle): + url = smart_urlquote('http://%s' % unescape(middle)) + elif ':' not in middle and is_email_simple(middle): + local, domain = middle.rsplit('@', 1) +Index: Django-2.2.28/tests/admin_widgets/tests.py +=================================================================== +--- Django-2.2.28.orig/tests/admin_widgets/tests.py ++++ Django-2.2.28/tests/admin_widgets/tests.py +@@ -336,7 +336,12 @@ class AdminSplitDateTimeWidgetTest(Simpl + class AdminURLWidgetTest(SimpleTestCase): + def test_get_context_validates_url(self): + w = widgets.AdminURLFieldWidget() +- for invalid in ['', '/not/a/full/url/', 'javascript:alert("Danger XSS!")']: ++ for invalid in [ ++ "", ++ "/not/a/full/url/", ++ 'javascript:alert("Danger XSS!")', ++ "http://" + "한.글." * 1_000_000 + "com", ++ ]: + with self.subTest(url=invalid): + self.assertFalse(w.get_context('name', invalid, {})['url_valid']) + self.assertTrue(w.get_context('name', 'http://example.com', {})['url_valid']) +Index: Django-2.2.28/tests/utils_tests/test_html.py +=================================================================== +--- Django-2.2.28.orig/tests/utils_tests/test_html.py ++++ Django-2.2.28/tests/utils_tests/test_html.py +@@ -261,6 +261,10 @@ class TestUtilsHtml(SimpleTestCase): + def test_urlize_unchanged_inputs(self): + tests = ( + ("a" + "@a" * 50000) + "a", # simple_email_re catastrophic test ++ # Unicode domain catastrophic tests. ++ "a@" + "한.글." * 1_000_000 + "a", ++ "http://" + "한.글." * 1_000_000 + "com", ++ "www." + "한.글." * 1_000_000 + "com", + ("a" + "." * 1000000) + "a", # trailing_punctuation catastrophic test + "foo@", + "@foo.com", diff --git a/CVE-2024-42005.patch b/CVE-2024-42005.patch new file mode 100644 index 0000000000000000000000000000000000000000..aa5c57a90911067a90c170138d71a3193b4bc02d --- /dev/null +++ b/CVE-2024-42005.patch @@ -0,0 +1,84 @@ +From b6de28f897709ee5d94ca2da21bcc98f9dade01c Mon Sep 17 00:00:00 2001 +From: Simon Charette +Date: Thu, 25 Jul 2024 18:19:13 +0200 +Subject: [PATCH 4/4] [4.2.x] Fixed CVE-2024-42005 -- Mitigated + QuerySet.values() SQL injection attacks against JSON fields. + +Thanks Eyal (eyalgabay) for the report. +--- + django/db/models/sql/query.py | 2 ++ + tests/expressions/models.py | 7 +++++++ + tests/expressions/test_queryset_values.py | 17 +++++++++++++++-- + 4 files changed, 31 insertions(+), 2 deletions(-) + +Index: Django-2.0.7/django/db/models/sql/query.py +=================================================================== +--- Django-2.0.7.orig/django/db/models/sql/query.py ++++ Django-2.0.7/django/db/models/sql/query.py +@@ -1924,6 +1924,8 @@ class Query: + self.clear_select_fields() + + if fields: ++ for field in fields: ++ self.check_alias(field) + field_names = [] + extra_names = [] + annotation_names = [] +Index: Django-2.0.7/tests/expressions/models.py +=================================================================== +--- Django-2.0.7.orig/tests/expressions/models.py ++++ Django-2.0.7/tests/expressions/models.py +@@ -4,6 +4,7 @@ Tests for F() query expression syntax. + import uuid + + from django.db import models ++from django.contrib.postgres.fields import JSONField + + + class Employee(models.Model): +@@ -91,3 +92,10 @@ class UUID(models.Model): + + def __str__(self): + return "%s" % self.uuid ++ ++ ++class JSONFieldModel(models.Model): ++ data = JSONField(null=True) ++ ++ class Meta: ++ required_db_features = {"supports_json_field"} +Index: Django-2.0.7/tests/expressions/test_queryset_values.py +=================================================================== +--- Django-2.0.7.orig/tests/expressions/test_queryset_values.py ++++ Django-2.0.7/tests/expressions/test_queryset_values.py +@@ -1,8 +1,8 @@ + from django.db.models.aggregates import Sum + from django.db.models.expressions import F +-from django.test import TestCase ++from django.test import TestCase, skipUnlessDBFeature + +-from .models import Company, Employee ++from .models import Company, Employee, JSONFieldModel + + + class ValuesExpressionsTests(TestCase): +@@ -36,6 +36,19 @@ class ValuesExpressionsTests(TestCase): + with self.assertRaisesMessage(ValueError, msg): + Company.objects.values(**{crafted_alias: F("ceo__salary")}) + ++ @skipUnlessDBFeature("supports_json_field") ++ def test_values_expression_alias_sql_injection_json_field(self): ++ crafted_alias = """injected_name" from "expressions_company"; --""" ++ msg = ( ++ "Column aliases cannot contain whitespace characters, quotation marks, " ++ "semicolons, or SQL comments." ++ ) ++ with self.assertRaisesMessage(ValueError, msg): ++ JSONFieldModel.objects.values(f"data__{crafted_alias}") ++ ++ with self.assertRaisesMessage(ValueError, msg): ++ JSONFieldModel.objects.values_list(f"data__{crafted_alias}") ++ + def test_values_expression_group_by(self): + # values() applies annotate() first, so values selected are grouped by + # id, not firstname. diff --git a/CVE-2024-45230.patch b/CVE-2024-45230.patch new file mode 100644 index 0000000000000000000000000000000000000000..497d53fce02b3719f40470d9e744bcc59c197eaf --- /dev/null +++ b/CVE-2024-45230.patch @@ -0,0 +1,133 @@ +From 65a776dd25b657cc32edafaad98d91aa0b51e641 Mon Sep 17 00:00:00 2001 +From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> +Date: Mon, 12 Aug 2024 15:17:57 +0200 +Subject: [PATCH 1/2] [4.2.x] Fixed CVE-2024-45230 -- Mitigated potential DoS + in urlize and urlizetrunc template filters. + +Thanks MProgrammer (https://hackerone.com/mprogrammer) for the report. +--- + django/utils/html.py | 17 ++++++++------ + docs/ref/templates/builtins.txt | 11 ++++++++++ + docs/releases/4.2.16.txt | 15 +++++++++++++ + docs/releases/index.txt | 1 + + .../filter_tests/test_urlize.py | 22 +++++++++++++++++++ + tests/utils_tests/test_html.py | 1 + + 6 files changed, 60 insertions(+), 7 deletions(-) + create mode 100644 docs/releases/4.2.16.txt + +Index: Django-2.2.28/django/utils/html.py +=================================================================== +--- Django-2.2.28.orig/django/utils/html.py ++++ Django-2.2.28/django/utils/html.py +@@ -322,14 +322,17 @@ def urlize(text, trim_url_limit=None, no + potential_entity = middle[amp:] + escaped = html.unescape(potential_entity) + if escaped == potential_entity or escaped.endswith(";"): +- rstripped = middle.rstrip(";") +- amount_stripped = len(middle) - len(rstripped) +- if amp > -1 and amount_stripped > 1: +- # Leave a trailing semicolon as might be an entity. +- trail = middle[len(rstripped) + 1 :] + trail +- middle = rstripped + ";" ++ rstripped = middle.rstrip(TRAILING_PUNCTUATION_CHARS) ++ trail_start = len(rstripped) ++ amount_trailing_semicolons = len(middle) - len(middle.rstrip(";")) ++ if amp > -1 and amount_trailing_semicolons > 1: ++ # Leave up to most recent semicolon as might be an entity. ++ recent_semicolon = middle[trail_start:].index(";") ++ middle_semicolon_index = recent_semicolon + trail_start + 1 ++ trail = middle[middle_semicolon_index:] + trail ++ middle = rstripped + middle[trail_start:middle_semicolon_index] + else: +- trail = middle[len(rstripped) :] + trail ++ trail = middle[trail_start:] + trail + middle = rstripped + trimmed_something = True + # Trim trailing punctuation (after trimming wrapping punctuation, +Index: Django-2.2.28/docs/ref/templates/builtins.txt +=================================================================== +--- Django-2.2.28.orig/docs/ref/templates/builtins.txt ++++ Django-2.2.28/docs/ref/templates/builtins.txt +@@ -2463,6 +2463,17 @@ Django's built-in :tfilter:`escape` filt + email addresses that contain single quotes (``'``), things won't work as + expected. Apply this filter only to plain text. + ++.. warning:: ++ ++ Using ``urlize`` or ``urlizetrunc`` can incur a performance penalty, which ++ can become severe when applied to user controlled values such as content ++ stored in a :class:`~django.db.models.TextField`. You can use ++ :tfilter:`truncatechars` to add a limit to such inputs: ++ ++ .. code-block:: html+django ++ ++ {{ value|truncatechars:500|urlize }} ++ + .. templatefilter:: urlizetrunc + + ``urlizetrunc`` +Index: Django-2.2.28/docs/releases/4.2.16.txt +=================================================================== +--- /dev/null ++++ Django-2.2.28/docs/releases/4.2.16.txt +@@ -0,0 +1,15 @@ ++=========================== ++Django 4.2.16 release notes ++=========================== ++ ++*September 3, 2024* ++ ++Django 4.2.16 fixes one security issue with severity "moderate" and one ++security issues with severity "low" in 4.2.15. ++ ++CVE-2024-45230: Potential denial-of-service vulnerability in ``django.utils.html.urlize()`` ++=========================================================================================== ++ ++:tfilter:`urlize` and :tfilter:`urlizetrunc` were subject to a potential ++denial-of-service attack via very large inputs with a specific sequence of ++characters. +Index: Django-2.2.28/tests/template_tests/filter_tests/test_urlize.py +=================================================================== +--- Django-2.2.28.orig/tests/template_tests/filter_tests/test_urlize.py ++++ Django-2.2.28/tests/template_tests/filter_tests/test_urlize.py +@@ -260,6 +260,28 @@ class FunctionTests(SimpleTestCase): + 'A test http://testing.com/example.,:;)"!' + ) + ++ def test_trailing_semicolon(self): ++ self.assertEqual( ++ urlize("http://example.com?x=&", autoescape=False), ++ '' ++ "http://example.com?x=&", ++ ) ++ self.assertEqual( ++ urlize("http://example.com?x=&;", autoescape=False), ++ '' ++ "http://example.com?x=&;", ++ ) ++ self.assertEqual( ++ urlize("http://example.com?x=&;;", autoescape=False), ++ '' ++ "http://example.com?x=&;;", ++ ) ++ self.assertEqual( ++ urlize("http://example.com?x=&.;...;", autoescape=False), ++ '' ++ "http://example.com?x=&.;...;", ++ ) ++ + def test_brackets(self): + """ + #19070 - Check urlize handles brackets properly +Index: Django-2.2.28/tests/utils_tests/test_html.py +=================================================================== +--- Django-2.2.28.orig/tests/utils_tests/test_html.py ++++ Django-2.2.28/tests/utils_tests/test_html.py +@@ -282,6 +282,7 @@ class TestUtilsHtml(SimpleTestCase): + "&:" + ";" * 100_000, + "&.;" * 100_000, + ".;" * 100_000, ++ "&" + ";:" * 100_000, + ) + for value in tests: + with self.subTest(value=value): diff --git a/CVE-2024-45231.patch b/CVE-2024-45231.patch new file mode 100644 index 0000000000000000000000000000000000000000..42ec6cc48aa12f700dd610bf03fc00e659c3b3f0 --- /dev/null +++ b/CVE-2024-45231.patch @@ -0,0 +1,133 @@ +From fe42da9cdacd9f43fb0d499244314c36f9a11a19 Mon Sep 17 00:00:00 2001 +From: Natalia <124304+nessita@users.noreply.github.com> +Date: Mon, 19 Aug 2024 14:47:38 -0300 +Subject: [PATCH 2/2] [4.2.x] Fixed CVE-2024-45231 -- Avoided server error on + password reset when email sending fails. + +On successful submission of a password reset request, an email is sent +to the accounts known to the system. If sending this email fails (due to +email backend misconfiguration, service provider outage, network issues, +etc.), an attacker might exploit this by detecting which password reset +requests succeed and which ones generate a 500 error response. + +Thanks to Thibaut Spriet for the report, and to Mariusz Felisiak and +Sarah Boyce for the reviews. +--- + django/contrib/auth/forms.py | 9 ++++++++- + docs/ref/logging.txt | 12 ++++++++++++ + docs/releases/4.2.16.txt | 11 +++++++++++ + docs/topics/auth/default.txt | 4 +++- + tests/auth_tests/test_forms.py | 21 +++++++++++++++++++++ + tests/mail/custombackend.py | 5 +++++ + 6 files changed, 60 insertions(+), 2 deletions(-) + +Index: Django-2.2.28/django/contrib/auth/forms.py +=================================================================== +--- Django-2.2.28.orig/django/contrib/auth/forms.py ++++ Django-2.2.28/django/contrib/auth/forms.py +@@ -1,3 +1,4 @@ ++import logging + import unicodedata + + from django import forms +@@ -18,6 +19,7 @@ from django.utils.text import capfirst + from django.utils.translation import gettext, gettext_lazy as _ + + UserModel = get_user_model() ++logger = logging.getLogger("django.contrib.auth") + + + def _unicode_ci_compare(s1, s2): +@@ -256,7 +258,12 @@ class PasswordResetForm(forms.Form): + html_email = loader.render_to_string(html_email_template_name, context) + email_message.attach_alternative(html_email, 'text/html') + +- email_message.send() ++ try: ++ email_message.send() ++ except Exception: ++ logger.exception( ++ "Failed to send password reset email to %s:", context["user"].pk ++ ) + + def get_users(self, email): + """Given an email, return matching user(s) who should receive a reset. +Index: Django-2.2.28/docs/releases/4.2.16.txt +=================================================================== +--- Django-2.2.28.orig/docs/releases/4.2.16.txt ++++ Django-2.2.28/docs/releases/4.2.16.txt +@@ -13,3 +13,14 @@ CVE-2024-45230: Potential denial-of-serv + :tfilter:`urlize` and :tfilter:`urlizetrunc` were subject to a potential + denial-of-service attack via very large inputs with a specific sequence of + characters. ++ ++CVE-2024-45231: Potential user email enumeration via response status on password reset ++====================================================================================== ++ ++Due to unhandled email sending failures, the ++:class:`~django.contrib.auth.forms.PasswordResetForm` class allowed remote ++attackers to enumerate user emails by issuing password reset requests and ++observing the outcomes. ++ ++To mitigate this risk, exceptions occurring during password reset email sending ++are now handled and logged using the :ref:`django-contrib-auth-logger` logger. +Index: Django-2.2.28/docs/topics/auth/default.txt +=================================================================== +--- Django-2.2.28.orig/docs/topics/auth/default.txt ++++ Django-2.2.28/docs/topics/auth/default.txt +@@ -1530,7 +1530,9 @@ provides several built-in forms located + .. method:: send_mail(subject_template_name, email_template_name, context, from_email, to_email, html_email_template_name=None) + + Uses the arguments to send an ``EmailMultiAlternatives``. +- Can be overridden to customize how the email is sent to the user. ++ Can be overridden to customize how the email is sent to the user. If ++ you choose to override this method, be mindful of handling potential ++ exceptions raised due to email sending failures. + + :param subject_template_name: the template for the subject. + :param email_template_name: the template for the email body. +Index: Django-2.2.28/tests/auth_tests/test_forms.py +=================================================================== +--- Django-2.2.28.orig/tests/auth_tests/test_forms.py ++++ Django-2.2.28/tests/auth_tests/test_forms.py +@@ -929,6 +929,27 @@ class PasswordResetFormTest(TestDataMixi + message.get_payload(1).get_payload() + )) + ++ @override_settings(EMAIL_BACKEND="mail.custombackend.FailingEmailBackend") ++ def test_save_send_email_exceptions_are_catched_and_logged(self): ++ (user, username, email) = self.create_dummy_user() ++ form = PasswordResetForm({"email": email}) ++ self.assertTrue(form.is_valid()) ++ ++ with self.assertLogs("django.contrib.auth", level=0) as cm: ++ form.save() ++ ++ self.assertEqual(len(mail.outbox), 0) ++ self.assertEqual(len(cm.output), 1) ++ errors = cm.output[0].split("\n") ++ pk = user.pk ++ self.assertEqual( ++ errors[0], ++ f"ERROR:django.contrib.auth:Failed to send password reset email to {pk}:", ++ ) ++ self.assertEqual( ++ errors[-1], "ValueError: FailingEmailBackend is doomed to fail." ++ ) ++ + @override_settings(AUTH_USER_MODEL='auth_tests.CustomEmailField') + def test_custom_email_field(self): + email = 'test@mail.com' +Index: Django-2.2.28/tests/mail/custombackend.py +=================================================================== +--- Django-2.2.28.orig/tests/mail/custombackend.py ++++ Django-2.2.28/tests/mail/custombackend.py +@@ -13,3 +13,8 @@ class EmailBackend(BaseEmailBackend): + # Messages are stored in an instance variable for testing. + self.test_outbox.extend(email_messages) + return len(email_messages) ++ ++ ++class FailingEmailBackend(BaseEmailBackend): ++ def send_messages(self, email_messages): ++ raise ValueError("FailingEmailBackend is doomed to fail.") diff --git a/python-django.spec b/python-django.spec index 16cfb5138f7ecf8a8ed4cb590cffc3b28647f9b7..e1ed2a12608c2c1a18eef3b25db7878547a41590 100644 --- a/python-django.spec +++ b/python-django.spec @@ -1,7 +1,7 @@ %global _empty_manifest_terminate_build 0 Name: python-django Version: 2.2.27 -Release: 11 +Release: 12 Summary: A high-level Python Web framework that encourages rapid development and clean, pragmatic design. License: Apache-2.0 and Python-2.0 and OFL-1.1 and MIT URL: https://www.djangoproject.com/ @@ -25,6 +25,17 @@ Patch8: CVE-2023-46695.patch Patch9: CVE-2024-24680.patch # https://github.com/django/django/commit/072963e4c4d0b3a7a8c5412bc0c7d27d1a9c3521 Patch10: CVE-2024-27351.patch +# patch11-20 origin: https://build.opensuse.org/package/show/openSUSE:Backports:SLE-15-SP5:Update/python-Django +Patch11: CVE-2024-38875.patch +Patch12: CVE-2024-39329.patch +Patch13: CVE-2024-39330.patch +Patch14: CVE-2024-39614.patch +Patch15: CVE-2024-41989.patch +Patch16: CVE-2024-41990.patch +Patch17: CVE-2024-41991.patch +Patch18: CVE-2024-42005.patch +Patch19: CVE-2024-45230.patch +Patch20: CVE-2024-45231.patch BuildArch: noarch %description @@ -91,6 +102,10 @@ mv %{buildroot}/doclist.lst . %{_docdir}/* %changelog +* Fri Oct 11 2024 wangkai <13474090681@163.com> - 2.2.27-12 +- Fix CVE-2024-38875 CVE-2024-39329 CVE-2024-39330 CVE-2024-39614 CVE-2024-41989 + CVE-2024-41990 CVE-2024-41991 CVE-2024-42005 CVE-2024-45230 CVE-2024-45231 + * Tue Mar 05 2024 yaoxin - 2.2.27-11 - Fix CVE-2024-27351