Sync from SUSE:ALP:Source:Standard:1.0 python-Django revision 97369dd51bd1a21aa7bbf35c864aa360
This commit is contained in:
parent
7d09936fd7
commit
572e03dca0
164
CVE-2024-38875.patch
Normal file
164
CVE-2024-38875.patch
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
From 79f368764295df109a37192f6182fb6f361d85b5 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Adam Johnson <me@adamj.eu>
|
||||||
|
Date: Mon, 24 Jun 2024 15:30:59 +0200
|
||||||
|
Subject: [PATCH] [4.2.x] Fixed CVE-2024-38875 -- Mitigated potential DoS in
|
||||||
|
urlize and urlizetrunc template filters.
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset=UTF-8
|
||||||
|
Content-Transfer-Encoding: 8bit
|
||||||
|
|
||||||
|
Thank you to Elias Myllymäki for the report.
|
||||||
|
|
||||||
|
Co-authored-by: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com>
|
||||||
|
---
|
||||||
|
django/utils/html.py | 90 +++++++++++++++++++++++++---------
|
||||||
|
tests/utils_tests/test_html.py | 7 +++
|
||||||
|
2 files changed, 73 insertions(+), 24 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/django/utils/html.py b/django/utils/html.py
|
||||||
|
index fdb88d6709..fd313ff9ca 100644
|
||||||
|
--- a/django/utils/html.py
|
||||||
|
+++ b/django/utils/html.py
|
||||||
|
@@ -7,7 +7,7 @@ from html.parser import HTMLParser
|
||||||
|
from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
from django.utils.encoding import punycode
|
||||||
|
-from django.utils.functional import Promise, keep_lazy, keep_lazy_text
|
||||||
|
+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.regex_helper import _lazy_re_compile
|
||||||
|
from django.utils.safestring import SafeData, SafeString, mark_safe
|
||||||
|
@@ -225,6 +225,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]
|
||||||
|
+
|
||||||
|
+
|
||||||
|
class Urlizer:
|
||||||
|
"""
|
||||||
|
Convert any URLs in text into clickable links.
|
||||||
|
@@ -330,40 +340,72 @@ class Urlizer:
|
||||||
|
return x
|
||||||
|
return "%s…" % x[: max(0, limit - 1)]
|
||||||
|
|
||||||
|
+ @cached_property
|
||||||
|
+ def wrapping_punctuation_openings(self):
|
||||||
|
+ return "".join(dict(self.wrapping_punctuation).keys())
|
||||||
|
+
|
||||||
|
+ @cached_property
|
||||||
|
+ def trailing_punctuation_chars_no_semicolon(self):
|
||||||
|
+ return self.trailing_punctuation_chars.replace(";", "")
|
||||||
|
+
|
||||||
|
+ @cached_property
|
||||||
|
+ def trailing_punctuation_chars_has_semicolon(self):
|
||||||
|
+ return ";" in self.trailing_punctuation_chars
|
||||||
|
+
|
||||||
|
def trim_punctuation(self, word):
|
||||||
|
"""
|
||||||
|
Trim trailing and wrapping punctuation from `word`. Return the items of
|
||||||
|
the new state.
|
||||||
|
"""
|
||||||
|
- lead, middle, trail = "", word, ""
|
||||||
|
+ # Strip all opening wrapping punctuation.
|
||||||
|
+ middle = word.lstrip(self.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 self.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
|
||||||
|
- trimmed_something = True
|
||||||
|
- # Trim trailing punctuation (after trimming wrapping punctuation,
|
||||||
|
- # as encoded entities contain ';'). Unescape entities to avoid
|
||||||
|
- # breaking them by removing ';'.
|
||||||
|
- middle_unescaped = html.unescape(middle)
|
||||||
|
- stripped = middle_unescaped.rstrip(self.trailing_punctuation_chars)
|
||||||
|
- if middle_unescaped != stripped:
|
||||||
|
- punctuation_count = len(middle_unescaped) - len(stripped)
|
||||||
|
- trail = middle[-punctuation_count:] + trail
|
||||||
|
- middle = middle[:-punctuation_count]
|
||||||
|
+ 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(self.trailing_punctuation_chars_no_semicolon)
|
||||||
|
+ if rstripped != middle:
|
||||||
|
+ trail = middle[len(rstripped) :] + trail
|
||||||
|
+ middle = rstripped
|
||||||
|
trimmed_something = True
|
||||||
|
+
|
||||||
|
+ if self.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
|
||||||
|
+
|
||||||
|
return lead, middle, trail
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
diff --git a/tests/utils_tests/test_html.py b/tests/utils_tests/test_html.py
|
||||||
|
index b7a7396075..6dab41634a 100644
|
||||||
|
--- a/tests/utils_tests/test_html.py
|
||||||
|
+++ b/tests/utils_tests/test_html.py
|
||||||
|
@@ -342,6 +342,13 @@ class TestUtilsHtml(SimpleTestCase):
|
||||||
|
"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
|
||||||
|
|
87
CVE-2024-39329.patch
Normal file
87
CVE-2024-39329.patch
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
From 156d3186c96e3ec2ca73b8b25dc2ef366e38df14 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Michael Manfre <mike@manfre.net>
|
||||||
|
Date: Fri, 14 Jun 2024 22:12:58 -0400
|
||||||
|
Subject: [PATCH] [4.2.x] 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 | 32 ++++++++++++++++++++++++++++++++
|
||||||
|
2 files changed, 40 insertions(+), 2 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py
|
||||||
|
index 9db5a12e13..f11bc8a0f3 100644
|
||||||
|
--- a/django/contrib/auth/hashers.py
|
||||||
|
+++ b/django/contrib/auth/hashers.py
|
||||||
|
@@ -43,14 +43,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 36f22d5f09..3da495f2be 100644
|
||||||
|
--- a/tests/auth_tests/test_hashers.py
|
||||||
|
+++ b/tests/auth_tests/test_hashers.py
|
||||||
|
@@ -613,6 +613,38 @@ 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),
|
||||||
|
+ mock.patch(
|
||||||
|
+ "django.contrib.auth.hashers.identify_hasher",
|
||||||
|
+ side_effect=hasher_side_effect,
|
||||||
|
+ ) as mock_identify_hasher,
|
||||||
|
+ mock.patch(
|
||||||
|
+ "django.contrib.auth.hashers.make_password"
|
||||||
|
+ ) as mock_make_password,
|
||||||
|
+ mock.patch(
|
||||||
|
+ "django.contrib.auth.hashers.get_random_string",
|
||||||
|
+ side_effect=lambda size: "x" * size,
|
||||||
|
+ ),
|
||||||
|
+ 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)],
|
||||||
|
+ )
|
||||||
|
+
|
||||||
|
def test_encode_invalid_salt(self):
|
||||||
|
hasher_classes = [
|
||||||
|
MD5PasswordHasher,
|
||||||
|
--
|
||||||
|
2.45.2
|
||||||
|
|
180
CVE-2024-39330.patch
Normal file
180
CVE-2024-39330.patch
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
From 2b00edc0151a660d1eb86da4059904a0fc4e095e Mon Sep 17 00:00:00 2001
|
||||||
|
From: Natalia <124304+nessita@users.noreply.github.com>
|
||||||
|
Date: Wed, 20 Mar 2024 13:55:21 -0300
|
||||||
|
Subject: [PATCH] [4.2.x] 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/base.py | 11 +++++
|
||||||
|
django/core/files/utils.py | 7 ++--
|
||||||
|
tests/file_storage/test_base.py | 70 +++++++++++++++++++++++++++++++
|
||||||
|
tests/file_storage/tests.py | 11 ++---
|
||||||
|
tests/file_uploads/tests.py | 2 +-
|
||||||
|
5 files changed, 88 insertions(+), 13 deletions(-)
|
||||||
|
create mode 100644 tests/file_storage/test_base.py
|
||||||
|
|
||||||
|
diff --git a/django/core/files/storage/base.py b/django/core/files/storage/base.py
|
||||||
|
index 16ac22f70a..03a1b44edb 100644
|
||||||
|
--- a/django/core/files/storage/base.py
|
||||||
|
+++ b/django/core/files/storage/base.py
|
||||||
|
@@ -34,7 +34,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 85342b2f3f..11e4f07724 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..c5338b8e66
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/tests/file_storage/test_base.py
|
||||||
|
@@ -0,0 +1,70 @@
|
||||||
|
+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),
|
||||||
|
+ mock.patch.object(s, "get_available_name") as mock_get_available_name,
|
||||||
|
+ 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_name.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),
|
||||||
|
+ mock.patch.object(s, "get_available_name", return_value=name),
|
||||||
|
+ 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),
|
||||||
|
+ 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 7fb57fbce4..44bea8c180 100644
|
||||||
|
--- a/tests/file_storage/tests.py
|
||||||
|
+++ b/tests/file_storage/tests.py
|
||||||
|
@@ -342,22 +342,17 @@ 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)
|
||||||
|
-
|
||||||
|
@unittest.skipUnless(
|
||||||
|
symlinks_supported(), "Must be able to symlink to run this test."
|
||||||
|
)
|
||||||
|
def test_file_save_broken_symlink(self):
|
||||||
|
"""A new path is created on save when a broken symlink is supplied."""
|
||||||
|
nonexistent_file_path = os.path.join(self.temp_dir, "nonexistent.txt")
|
||||||
|
- broken_symlink_path = os.path.join(self.temp_dir, "symlink.txt")
|
||||||
|
+ broken_symlink_file_name = "symlink.txt"
|
||||||
|
+ broken_symlink_path = os.path.join(self.temp_dir, broken_symlink_file_name)
|
||||||
|
os.symlink(nonexistent_file_path, broken_symlink_path)
|
||||||
|
f = ContentFile("some content")
|
||||||
|
- f_name = self.storage.save(broken_symlink_path, f)
|
||||||
|
+ f_name = self.storage.save(broken_symlink_file_name, f)
|
||||||
|
self.assertIs(os.path.exists(os.path.join(self.temp_dir, f_name)), True)
|
||||||
|
|
||||||
|
def test_save_doesnt_close(self):
|
||||||
|
diff --git a/tests/file_uploads/tests.py b/tests/file_uploads/tests.py
|
||||||
|
index 693efc4c62..24c703a309 100644
|
||||||
|
--- a/tests/file_uploads/tests.py
|
||||||
|
+++ b/tests/file_uploads/tests.py
|
||||||
|
@@ -826,7 +826,7 @@ class DirectoryCreationTests(SimpleTestCase):
|
||||||
|
default_storage.delete(UPLOAD_TO)
|
||||||
|
# Create a file with the upload directory name
|
||||||
|
with SimpleUploadedFile(UPLOAD_TO, b"x") as file:
|
||||||
|
- default_storage.save(UPLOAD_TO, file)
|
||||||
|
+ default_storage.save(UPLOAD_FOLDER, file)
|
||||||
|
self.addCleanup(default_storage.delete, UPLOAD_TO)
|
||||||
|
msg = "%s exists and is not a directory." % UPLOAD_TO
|
||||||
|
with self.assertRaisesMessage(FileExistsError, msg):
|
||||||
|
--
|
||||||
|
2.45.2
|
||||||
|
|
135
CVE-2024-39614.patch
Normal file
135
CVE-2024-39614.patch
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
From 17358fb35fb7217423d4c4877ccb6d1a3a40b1c3 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com>
|
||||||
|
Date: Wed, 26 Jun 2024 12:11:54 +0200
|
||||||
|
Subject: [PATCH] [4.2.x] 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 | 25 ++++++++++++++++++++-----
|
||||||
|
docs/ref/utils.txt | 10 ++++++++++
|
||||||
|
tests/i18n/tests.py | 11 +++++++++++
|
||||||
|
3 files changed, 41 insertions(+), 5 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/django/utils/translation/trans_real.py b/django/utils/translation/trans_real.py
|
||||||
|
index 872c80b00f..ce13d1e69c 100644
|
||||||
|
--- a/django/utils/translation/trans_real.py
|
||||||
|
+++ b/django/utils/translation/trans_real.py
|
||||||
|
@@ -31,9 +31,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 9110 Sections 12.4.2 and
|
||||||
|
# 12.5.4, and RFC 5646 Section 2.1.
|
||||||
|
@@ -497,11 +498,25 @@ 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
|
||||||
|
<https://www.djangoproject.com/weblog/2007/oct/26/security-fix/>.
|
||||||
|
"""
|
||||||
|
if lang_code:
|
||||||
|
+ # Truncate the language code to a maximum length to avoid potential
|
||||||
|
+ # denial of service attacks.
|
||||||
|
+ if len(lang_code) > LANGUAGE_CODE_MAX_LENGTH:
|
||||||
|
+ if (
|
||||||
|
+ not strict
|
||||||
|
+ and (index := lang_code.rfind("-", 0, LANGUAGE_CODE_MAX_LENGTH)) > 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]
|
||||||
|
@@ -625,13 +640,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.
|
||||||
|
- if (index := lang_string.rfind(",", 0, ACCEPT_LANGUAGE_HEADER_MAX_LENGTH)) > 0:
|
||||||
|
+ if (index := lang_string.rfind(",", 0, LANGUAGE_CODE_MAX_LENGTH)) > 0:
|
||||||
|
return _parse_accept_lang_header(lang_string[:index])
|
||||||
|
|
||||||
|
# Don't attempt to parse if there is only one language-range value which is
|
||||||
|
diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt
|
||||||
|
index b2b826684d..471a4b31eb 100644
|
||||||
|
--- a/docs/ref/utils.txt
|
||||||
|
+++ b/docs/ref/utils.txt
|
||||||
|
@@ -1155,6 +1155,11 @@ For a complete discussion on the usage of the following see the
|
||||||
|
``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
|
||||||
|
@@ -1163,6 +1168,11 @@ For a complete discussion on the usage of the following see the
|
||||||
|
|
||||||
|
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 f517c5eac7..9b029d5992 100644
|
||||||
|
--- a/tests/i18n/tests.py
|
||||||
|
+++ b/tests/i18n/tests.py
|
||||||
|
@@ -65,6 +65,7 @@ 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
|
||||||
|
@@ -1888,6 +1889,16 @@ class MiscTests(SimpleTestCase):
|
||||||
|
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
|
||||||
|
--
|
||||||
|
2.45.2
|
||||||
|
|
@ -1,3 +1,19 @@
|
|||||||
|
-------------------------------------------------------------------
|
||||||
|
Fri Jul 12 13:41:03 UTC 2024 - Nico Krapp <nico.krapp@suse.com>
|
||||||
|
|
||||||
|
- Add CVE-2024-38875.patch (bsc#1227590)
|
||||||
|
* CVE-2024-38875: Potential denial-of-service attack via
|
||||||
|
certain inputs with a very large number of brackets
|
||||||
|
- Add CVE-2024-39329.patch (bsc#1227593)
|
||||||
|
* CVE-2024-39329: Username enumeration through timing difference
|
||||||
|
for users with unusable passwords
|
||||||
|
- Add CVE-2024-39330.patch (bsc#1227594)
|
||||||
|
* CVE-2024-39330: Potential directory traversal in
|
||||||
|
django.core.files.storage.Storage.save()
|
||||||
|
- Add CVE-2024-39614.patch (bsc#1227595)
|
||||||
|
* CVE-2024-39614: Potential denial-of-service through
|
||||||
|
django.utils.translation.get_supported_language_variant()
|
||||||
|
|
||||||
-------------------------------------------------------------------
|
-------------------------------------------------------------------
|
||||||
Thu Feb 29 13:19:00 UTC 2024 - Alberto Planas Dominguez <aplanas@suse.com>
|
Thu Feb 29 13:19:00 UTC 2024 - Alberto Planas Dominguez <aplanas@suse.com>
|
||||||
|
|
||||||
|
@ -37,6 +37,14 @@ Source99: python-Django-rpmlintrc
|
|||||||
Patch: sanitize_address.patch
|
Patch: sanitize_address.patch
|
||||||
# PATCH-FIX-UPSTREAM CVE-2024-27351.patch bsc#1220358
|
# PATCH-FIX-UPSTREAM CVE-2024-27351.patch bsc#1220358
|
||||||
Patch1: CVE-2024-27351.patch
|
Patch1: CVE-2024-27351.patch
|
||||||
|
# PATCH-FIX-UPSTREAM CVE-2024-38875.patch bsc#1227590
|
||||||
|
Patch2: CVE-2024-38875.patch
|
||||||
|
# PATCH-FIX-UPSTREAM CVE-2024-39329.patch bsc#1227593
|
||||||
|
Patch3: CVE-2024-39329.patch
|
||||||
|
# PATCH-FIX-UPSTREAM CVE-2024-39330.patch bsc#1227594
|
||||||
|
Patch4: CVE-2024-39330.patch
|
||||||
|
# PATCH-FIX-UPSTREAM CVE-2024-39614.patch bsc#1227595
|
||||||
|
Patch5: CVE-2024-39614.patch
|
||||||
BuildRequires: %{python_module Jinja2 >= 2.9.2}
|
BuildRequires: %{python_module Jinja2 >= 2.9.2}
|
||||||
BuildRequires: %{python_module Pillow >= 6.2.0}
|
BuildRequires: %{python_module Pillow >= 6.2.0}
|
||||||
BuildRequires: %{python_module PyYAML}
|
BuildRequires: %{python_module PyYAML}
|
||||||
|
Loading…
Reference in New Issue
Block a user