Update to 4.2.15 for fix cves

(cherry picked from commit a99ad9eb675e702e84ae0e6e5378f4e89272b679)
This commit is contained in:
starlet-dx 2024-08-08 14:39:18 +08:00 committed by openeuler-sync-bot
parent 196cacec5f
commit 26a8ae419d
13 changed files with 17 additions and 1910 deletions

View File

@ -1,109 +0,0 @@
From a9010fe5555e6086a9d9ae50069579400ef0685e Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak <felisiak.mariusz@gmail.com>
Date: Wed, 22 Jun 2022 12:44:04 +0200
Subject: [PATCH] [3.2.x] Fixed CVE-2022-34265 -- Protected
Trunc(kind)/Extract(lookup_name) against SQL injection.
Thanks Takuto Yoshikai (Aeye Security Lab) for the report.
---
django/db/backends/base/operations.py | 3 ++
django/db/models/functions/datetime.py | 4 +++
docs/releases/3.2.14.txt | 11 ++++++
.../datetime/test_extract_trunc.py | 34 +++++++++++++++++++
4 files changed, 52 insertions(+)
diff --git a/django/db/backends/base/operations.py b/django/db/backends/base/operations.py
index 0fcc607bcfb0..cdcd9885ba27 100644
--- a/django/db/backends/base/operations.py
+++ b/django/db/backends/base/operations.py
@@ -9,6 +9,7 @@
from django.db.backends import utils
from django.utils import timezone
from django.utils.encoding import force_str
+from django.utils.regex_helper import _lazy_re_compile
class BaseDatabaseOperations:
@@ -53,6 +54,8 @@ class BaseDatabaseOperations:
# Prefix for EXPLAIN queries, or None EXPLAIN isn't supported.
explain_prefix = None
+ extract_trunc_lookup_pattern = _lazy_re_compile(r"[\w\-_()]+")
+
def __init__(self, connection):
self.connection = connection
self._cache = None
diff --git a/django/db/models/functions/datetime.py b/django/db/models/functions/datetime.py
index 90e6f41be057..47651d281f19 100644
--- a/django/db/models/functions/datetime.py
+++ b/django/db/models/functions/datetime.py
@@ -41,6 +41,8 @@ def __init__(self, expression, lookup_name=None, tzinfo=None, **extra):
super().__init__(expression, **extra)
def as_sql(self, compiler, connection):
+ if not connection.ops.extract_trunc_lookup_pattern.fullmatch(self.lookup_name):
+ raise ValueError("Invalid lookup_name: %s" % self.lookup_name)
sql, params = compiler.compile(self.lhs)
lhs_output_field = self.lhs.output_field
if isinstance(lhs_output_field, DateTimeField):
@@ -192,6 +194,8 @@ def __init__(self, expression, output_field=None, tzinfo=None, is_dst=None, **ex
super().__init__(expression, output_field=output_field, **extra)
def as_sql(self, compiler, connection):
+ if not connection.ops.extract_trunc_lookup_pattern.fullmatch(self.kind):
+ raise ValueError("Invalid kind: %s" % self.kind)
inner_sql, inner_params = compiler.compile(self.lhs)
tzname = None
if isinstance(self.lhs.output_field, DateTimeField):
diff --git a/tests/db_functions/datetime/test_extract_trunc.py b/tests/db_functions/datetime/test_extract_trunc.py
index 258600127f93..27ed3ae63ee5 100644
--- a/tests/db_functions/datetime/test_extract_trunc.py
+++ b/tests/db_functions/datetime/test_extract_trunc.py
@@ -177,6 +177,23 @@ def test_extract_year_lessthan_lookup(self):
self.assertEqual(qs.count(), 1)
self.assertGreaterEqual(str(qs.query).lower().count('extract'), 2)
+ def test_extract_lookup_name_sql_injection(self):
+ start_datetime = datetime(2015, 6, 15, 14, 30, 50, 321)
+ end_datetime = datetime(2016, 6, 15, 14, 10, 50, 123)
+ if settings.USE_TZ:
+ start_datetime = timezone.make_aware(start_datetime)
+ end_datetime = timezone.make_aware(end_datetime)
+ self.create_model(start_datetime, end_datetime)
+ self.create_model(end_datetime, start_datetime)
+
+ msg = "Invalid lookup_name: "
+ with self.assertRaisesMessage(ValueError, msg):
+ DTModel.objects.filter(
+ start_datetime__year=Extract(
+ "start_datetime", "day' FROM start_datetime)) OR 1=1;--"
+ )
+ ).exists()
+
def test_extract_func(self):
start_datetime = datetime(2015, 6, 15, 14, 30, 50, 321)
end_datetime = datetime(2016, 6, 15, 14, 10, 50, 123)
@@ -620,6 +637,23 @@ def test_extract_second_func(self):
)
self.assertEqual(DTModel.objects.filter(start_datetime__second=ExtractSecond('start_datetime')).count(), 2)
+ def test_trunc_lookup_name_sql_injection(self):
+ start_datetime = datetime(2015, 6, 15, 14, 30, 50, 321)
+ end_datetime = datetime(2016, 6, 15, 14, 10, 50, 123)
+ if settings.USE_TZ:
+ start_datetime = timezone.make_aware(start_datetime)
+ end_datetime = timezone.make_aware(end_datetime)
+ self.create_model(start_datetime, end_datetime)
+ self.create_model(end_datetime, start_datetime)
+ msg = "Invalid kind: "
+ with self.assertRaisesMessage(ValueError, msg):
+ DTModel.objects.filter(
+ start_datetime__date=Trunc(
+ "start_datetime",
+ "year', start_datetime)) OR 1=1;--",
+ )
+ ).exists()
+
def test_trunc_func(self):
start_datetime = datetime(2015, 6, 15, 14, 30, 50, 321)
end_datetime = datetime(2016, 6, 15, 14, 10, 50, 123)

View File

@ -1,100 +0,0 @@
From 80353a42e41fd22933184a30f2e2c04d0c274c83 Mon Sep 17 00:00:00 2001
From: starlet-dx <15929766099@163.com>
Date: Mon, 13 Feb 2023 19:31:46 +0800
Subject: [PATCH 1/1] [3.2.x] Fixed CVE-2023-23969 -- Prevented DoS with pathological values for Accept-Language.
The parsed values of Accept-Language headers are cached in order to avoid repetitive parsing. This leads to a potential denial-of-service vector via excessive memory usage if the raw value of Accept-Language headers is very large.
Accept-Language headers are now limited to a maximum length in order to avoid this issue.
---
django/utils/translation/trans_real.py | 32 +++++++++++++++++++++++++-
tests/i18n/tests.py | 12 ++++++++++
2 files changed, 43 insertions(+), 1 deletion(-)
diff --git a/django/utils/translation/trans_real.py b/django/utils/translation/trans_real.py
index 8042f6f..b262a50 100644
--- a/django/utils/translation/trans_real.py
+++ b/django/utils/translation/trans_real.py
@@ -30,6 +30,11 @@ _default = None
# magic gettext number to separate context from message
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 MDNs Accept-Language page.
+ACCEPT_LANGUAGE_HEADER_MAX_LENGTH = 500
+
# Format of Accept-Language header values. From RFC 2616, section 14.4 and 3.9
# and RFC 3066, section 2.1
accept_language_re = _lazy_re_compile(r'''
@@ -556,7 +561,7 @@ def get_language_from_request(request, check_path=False):
@functools.lru_cache(maxsize=1000)
-def parse_accept_lang_header(lang_string):
+def _parse_accept_lang_header(lang_string):
"""
Parse the lang_string, which is the body of an HTTP Accept-Language
header, and return a tuple of (lang, q-value), ordered by 'q' values.
@@ -578,3 +583,28 @@ def parse_accept_lang_header(lang_string):
result.append((lang, priority))
result.sort(key=lambda k: k[1], reverse=True)
return tuple(result)
+
+
+def parse_accept_lang_header(lang_string):
+ """
+ Parse the value of the Accept-Language header up to a maximum length.
+
+ The value of the header is truncated to a maximum length to avoid potential
+ denial of service and memory exhaustion attacks. Excessive memory could be
+ used if the raw value is very large as it would be cached due to the use of
+ 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:
+ 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)
+ if index > 0:
+ return _parse_accept_lang_header(lang_string[:index])
+
+ # Don't attempt to parse if there is only one language-range value which is
+ # longer than the maximum allowed length and so truncated.
+ return ()
diff --git a/tests/i18n/tests.py b/tests/i18n/tests.py
index 7edceb1..f379f8f 100644
--- a/tests/i18n/tests.py
+++ b/tests/i18n/tests.py
@@ -1352,6 +1352,14 @@ class MiscTests(SimpleTestCase):
('de;q=0.', [('de', 0.0)]),
('en; q=1,', [('en', 1.0)]),
('en; q=1.0, * ; q=0.5', [('en', 1.0), ('*', 0.5)]),
+ (
+ 'en' + '-x' * 20,
+ [('en-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x', 1.0)],
+ ),
+ (
+ ', '.join(['en; q=1.0'] * 20),
+ [('en', 1.0)] * 20,
+ ),
# Bad headers
('en-gb;q=1.0000', []),
('en;q=0.1234', []),
@@ -1367,6 +1375,10 @@ class MiscTests(SimpleTestCase):
('12-345', []),
('', []),
('en;q=1e0', []),
+ # Invalid as language-range value too long.
+ ('xxxxxxxx' + '-xxxxxxxx' * 500, []),
+ # Header value too long, only parse up to limit.
+ (', '.join(['en; q=1.0'] * 500), [('en', 1.0)] * 45),
]
for value, expected in tests:
with self.subTest(value=value):
--
2.30.0

View File

@ -1,401 +0,0 @@
From a665ed5179f5bbd3db95ce67286d0192eff041d8 Mon Sep 17 00:00:00 2001
From: Markus Holtermann <info@markusholtermann.eu>
Date: Tue, 13 Dec 2022 10:27:39 +0100
Subject: [PATCH] [3.2.x] Fixed CVE-2023-24580 -- Prevented DoS with too many
uploaded files.
Thanks to Jakob Ackermann for the report.
---
django/conf/global_settings.py | 4 ++
django/core/exceptions.py | 9 +++
django/core/handlers/exception.py | 4 +-
django/http/multipartparser.py | 62 +++++++++++++++++----
django/http/request.py | 6 +-
docs/ref/exceptions.txt | 5 ++
docs/ref/settings.txt | 23 ++++++++
docs/releases/3.2.18.txt | 10 +++-
tests/handlers/test_exception.py | 28 +++++++++-
tests/requests/test_data_upload_settings.py | 51 ++++++++++++++++-
10 files changed, 184 insertions(+), 18 deletions(-)
diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py
index cf9fae496e3a..4a27887a8f04 100644
--- a/django/conf/global_settings.py
+++ b/django/conf/global_settings.py
@@ -303,6 +303,10 @@ def gettext_noop(s):
# SuspiciousOperation (TooManyFieldsSent) is raised.
DATA_UPLOAD_MAX_NUMBER_FIELDS = 1000
+# Maximum number of files encoded in a multipart upload that will be read
+# before a SuspiciousOperation (TooManyFilesSent) is raised.
+DATA_UPLOAD_MAX_NUMBER_FILES = 100
+
# Directory in which upload streamed files will be temporarily saved. A value of
# `None` will make Django use the operating system's default temporary directory
# (i.e. "/tmp" on *nix systems).
diff --git a/django/core/exceptions.py b/django/core/exceptions.py
index 673d004d5756..83161a58cd66 100644
--- a/django/core/exceptions.py
+++ b/django/core/exceptions.py
@@ -58,6 +58,15 @@ class TooManyFieldsSent(SuspiciousOperation):
pass
+class TooManyFilesSent(SuspiciousOperation):
+ """
+ The number of fields in a GET or POST request exceeded
+ settings.DATA_UPLOAD_MAX_NUMBER_FILES.
+ """
+
+ pass
+
+
class RequestDataTooBig(SuspiciousOperation):
"""
The size of the request (excluding any file uploads) exceeded
diff --git a/django/core/handlers/exception.py b/django/core/handlers/exception.py
index 3005a5eccb11..2ecc2a0fd697 100644
--- a/django/core/handlers/exception.py
+++ b/django/core/handlers/exception.py
@@ -9,7 +9,7 @@
from django.core import signals
from django.core.exceptions import (
BadRequest, PermissionDenied, RequestDataTooBig, SuspiciousOperation,
- TooManyFieldsSent,
+ TooManyFieldsSent, TooManyFilesSent,
)
from django.http import Http404
from django.http.multipartparser import MultiPartParserError
@@ -88,7 +88,7 @@ def response_for_exception(request, exc):
exc_info=sys.exc_info(),
)
elif isinstance(exc, SuspiciousOperation):
- if isinstance(exc, (RequestDataTooBig, TooManyFieldsSent)):
+ if isinstance(exc, (RequestDataTooBig, TooManyFieldsSent, TooManyFilesSent)):
# POST data can't be accessed again, otherwise the original
# exception would be raised.
request._mark_post_parse_error()
diff --git a/django/http/multipartparser.py b/django/http/multipartparser.py
index 35a54f4ca12e..d8a304d4babe 100644
--- a/django/http/multipartparser.py
+++ b/django/http/multipartparser.py
@@ -14,6 +14,7 @@
from django.conf import settings
from django.core.exceptions import (
RequestDataTooBig, SuspiciousMultipartForm, TooManyFieldsSent,
+ TooManyFilesSent,
)
from django.core.files.uploadhandler import (
SkipFile, StopFutureHandlers, StopUpload,
@@ -38,6 +39,7 @@ class InputStreamExhausted(Exception):
RAW = "raw"
FILE = "file"
FIELD = "field"
+FIELD_TYPES = frozenset([FIELD, RAW])
class MultiPartParser:
@@ -102,6 +104,22 @@ def __init__(self, META, input_data, upload_handlers, encoding=None):
self._upload_handlers = upload_handlers
def parse(self):
+ # Call the actual parse routine and close all open files in case of
+ # errors. This is needed because if exceptions are thrown the
+ # MultiPartParser will not be garbage collected immediately and
+ # resources would be kept alive. This is only needed for errors because
+ # the Request object closes all uploaded files at the end of the
+ # request.
+ try:
+ return self._parse()
+ except Exception:
+ if hasattr(self, "_files"):
+ for _, files in self._files.lists():
+ for fileobj in files:
+ fileobj.close()
+ raise
+
+ def _parse(self):
"""
Parse the POST data and break it into a FILES MultiValueDict and a POST
MultiValueDict.
@@ -147,6 +165,8 @@ def parse(self):
num_bytes_read = 0
# To count the number of keys in the request.
num_post_keys = 0
+ # To count the number of files in the request.
+ num_files = 0
# To limit the amount of data read from the request.
read_size = None
# Whether a file upload is finished.
@@ -162,6 +182,20 @@ def parse(self):
old_field_name = None
uploaded_file = True
+ if (
+ item_type in FIELD_TYPES and
+ settings.DATA_UPLOAD_MAX_NUMBER_FIELDS is not None
+ ):
+ # Avoid storing more than DATA_UPLOAD_MAX_NUMBER_FIELDS.
+ num_post_keys += 1
+ # 2 accounts for empty raw fields before and after the
+ # last boundary.
+ if settings.DATA_UPLOAD_MAX_NUMBER_FIELDS + 2 < num_post_keys:
+ raise TooManyFieldsSent(
+ "The number of GET/POST parameters exceeded "
+ "settings.DATA_UPLOAD_MAX_NUMBER_FIELDS."
+ )
+
try:
disposition = meta_data['content-disposition'][1]
field_name = disposition['name'].strip()
@@ -174,15 +208,6 @@ def parse(self):
field_name = force_str(field_name, encoding, errors='replace')
if item_type == FIELD:
- # Avoid storing more than DATA_UPLOAD_MAX_NUMBER_FIELDS.
- num_post_keys += 1
- if (settings.DATA_UPLOAD_MAX_NUMBER_FIELDS is not None and
- settings.DATA_UPLOAD_MAX_NUMBER_FIELDS < num_post_keys):
- raise TooManyFieldsSent(
- 'The number of GET/POST parameters exceeded '
- 'settings.DATA_UPLOAD_MAX_NUMBER_FIELDS.'
- )
-
# Avoid reading more than DATA_UPLOAD_MAX_MEMORY_SIZE.
if settings.DATA_UPLOAD_MAX_MEMORY_SIZE is not None:
read_size = settings.DATA_UPLOAD_MAX_MEMORY_SIZE - num_bytes_read
@@ -208,6 +233,16 @@ def parse(self):
self._post.appendlist(field_name, force_str(data, encoding, errors='replace'))
elif item_type == FILE:
+ # Avoid storing more than DATA_UPLOAD_MAX_NUMBER_FILES.
+ num_files += 1
+ if (
+ settings.DATA_UPLOAD_MAX_NUMBER_FILES is not None and
+ num_files > settings.DATA_UPLOAD_MAX_NUMBER_FILES
+ ):
+ raise TooManyFilesSent(
+ "The number of files exceeded "
+ "settings.DATA_UPLOAD_MAX_NUMBER_FILES."
+ )
# This is a file, use the handler...
file_name = disposition.get('filename')
if file_name:
@@ -276,8 +311,13 @@ def parse(self):
# Handle file upload completions on next iteration.
old_field_name = field_name
else:
- # If this is neither a FIELD or a FILE, just exhaust the stream.
- exhaust(stream)
+ # If this is neither a FIELD nor a FILE, exhaust the field
+ # stream. Note: There could be an error here at some point,
+ # but there will be at least two RAW types (before and
+ # after the other boundaries). This branch is usually not
+ # reached at all, because a missing content-disposition
+ # header will skip the whole boundary.
+ exhaust(field_stream)
except StopUpload as e:
self._close_files()
if not e.connection_reset:
diff --git a/django/http/request.py b/django/http/request.py
index 195341ec4b69..b6cd7a372f14 100644
--- a/django/http/request.py
+++ b/django/http/request.py
@@ -12,7 +12,9 @@
DisallowedHost, ImproperlyConfigured, RequestDataTooBig, TooManyFieldsSent,
)
from django.core.files import uploadhandler
-from django.http.multipartparser import MultiPartParser, MultiPartParserError
+from django.http.multipartparser import (
+ MultiPartParser, MultiPartParserError, TooManyFilesSent,
+)
from django.utils.datastructures import (
CaseInsensitiveMapping, ImmutableList, MultiValueDict,
)
@@ -360,7 +362,7 @@ def _load_post_and_files(self):
data = self
try:
self._post, self._files = self.parse_file_upload(self.META, data)
- except MultiPartParserError:
+ except (MultiPartParserError, TooManyFilesSent):
# An error occurred while parsing POST data. Since when
# formatting the error the request handler might access
# self.POST, set self._post and self._file to prevent
diff --git a/docs/ref/exceptions.txt b/docs/ref/exceptions.txt
index 2f5aa64b9d9d..7d34025cd65c 100644
--- a/docs/ref/exceptions.txt
+++ b/docs/ref/exceptions.txt
@@ -84,12 +84,17 @@ Django core exception classes are defined in ``django.core.exceptions``.
* ``SuspiciousMultipartForm``
* ``SuspiciousSession``
* ``TooManyFieldsSent``
+ * ``TooManyFilesSent``
If a ``SuspiciousOperation`` exception reaches the ASGI/WSGI handler level
it is logged at the ``Error`` level and results in
a :class:`~django.http.HttpResponseBadRequest`. See the :doc:`logging
documentation </topics/logging/>` for more information.
+.. versionchanged:: 3.2.18
+
+ ``SuspiciousOperation`` is raised when too many files are submitted.
+
``PermissionDenied``
--------------------
diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt
index 9bfadbc89bd2..9173009c94d5 100644
--- a/docs/ref/settings.txt
+++ b/docs/ref/settings.txt
@@ -1063,6 +1063,28 @@ could be used as a denial-of-service attack vector if left unchecked. Since web
servers don't typically perform deep request inspection, it's not possible to
perform a similar check at that level.
+.. setting:: DATA_UPLOAD_MAX_NUMBER_FILES
+
+``DATA_UPLOAD_MAX_NUMBER_FILES``
+--------------------------------
+
+.. versionadded:: 3.2.18
+
+Default: ``100``
+
+The maximum number of files that may be received via POST in a
+``multipart/form-data`` encoded request before a
+:exc:`~django.core.exceptions.SuspiciousOperation` (``TooManyFiles``) is
+raised. You can set this to ``None`` to disable the check. Applications that
+are expected to receive an unusually large number of file fields should tune
+this setting.
+
+The number of accepted files is correlated to the amount of time and memory
+needed to process the request. Large requests could be used as a
+denial-of-service attack vector if left unchecked. Since web servers don't
+typically perform deep request inspection, it's not possible to perform a
+similar check at that level.
+
.. setting:: DATABASE_ROUTERS
``DATABASE_ROUTERS``
@@ -3671,6 +3693,7 @@ HTTP
----
* :setting:`DATA_UPLOAD_MAX_MEMORY_SIZE`
* :setting:`DATA_UPLOAD_MAX_NUMBER_FIELDS`
+* :setting:`DATA_UPLOAD_MAX_NUMBER_FILES`
* :setting:`DEFAULT_CHARSET`
* :setting:`DISALLOWED_USER_AGENTS`
* :setting:`FORCE_SCRIPT_NAME`
diff --git a/tests/handlers/test_exception.py b/tests/handlers/test_exception.py
index 0c1e76399045..7de2edaeea34 100644
--- a/tests/handlers/test_exception.py
+++ b/tests/handlers/test_exception.py
@@ -1,6 +1,8 @@
from django.core.handlers.wsgi import WSGIHandler
from django.test import SimpleTestCase, override_settings
-from django.test.client import FakePayload
+from django.test.client import (
+ BOUNDARY, MULTIPART_CONTENT, FakePayload, encode_multipart,
+)
class ExceptionHandlerTests(SimpleTestCase):
@@ -25,3 +27,27 @@ def test_data_upload_max_memory_size_exceeded(self):
def test_data_upload_max_number_fields_exceeded(self):
response = WSGIHandler()(self.get_suspicious_environ(), lambda *a, **k: None)
self.assertEqual(response.status_code, 400)
+
+ @override_settings(DATA_UPLOAD_MAX_NUMBER_FILES=2)
+ def test_data_upload_max_number_files_exceeded(self):
+ payload = FakePayload(
+ encode_multipart(
+ BOUNDARY,
+ {
+ "a.txt": "Hello World!",
+ "b.txt": "Hello Django!",
+ "c.txt": "Hello Python!",
+ },
+ )
+ )
+ environ = {
+ "REQUEST_METHOD": "POST",
+ "CONTENT_TYPE": MULTIPART_CONTENT,
+ "CONTENT_LENGTH": len(payload),
+ "wsgi.input": payload,
+ "SERVER_NAME": "test",
+ "SERVER_PORT": "8000",
+ }
+
+ response = WSGIHandler()(environ, lambda *a, **k: None)
+ self.assertEqual(response.status_code, 400)
diff --git a/tests/requests/test_data_upload_settings.py b/tests/requests/test_data_upload_settings.py
index 44897cc9fa97..ded778b42286 100644
--- a/tests/requests/test_data_upload_settings.py
+++ b/tests/requests/test_data_upload_settings.py
@@ -1,11 +1,14 @@
from io import BytesIO
-from django.core.exceptions import RequestDataTooBig, TooManyFieldsSent
+from django.core.exceptions import (
+ RequestDataTooBig, TooManyFieldsSent, TooManyFilesSent,
+)
from django.core.handlers.wsgi import WSGIRequest
from django.test import SimpleTestCase
from django.test.client import FakePayload
TOO_MANY_FIELDS_MSG = 'The number of GET/POST parameters exceeded settings.DATA_UPLOAD_MAX_NUMBER_FIELDS.'
+TOO_MANY_FILES_MSG = 'The number of files exceeded settings.DATA_UPLOAD_MAX_NUMBER_FILES.'
TOO_MUCH_DATA_MSG = 'Request body exceeded settings.DATA_UPLOAD_MAX_MEMORY_SIZE.'
@@ -166,6 +169,52 @@ def test_no_limit(self):
self.request._load_post_and_files()
+class DataUploadMaxNumberOfFilesMultipartPost(SimpleTestCase):
+ def setUp(self):
+ payload = FakePayload(
+ "\r\n".join(
+ [
+ "--boundary",
+ (
+ 'Content-Disposition: form-data; name="name1"; '
+ 'filename="name1.txt"'
+ ),
+ "",
+ "value1",
+ "--boundary",
+ (
+ 'Content-Disposition: form-data; name="name2"; '
+ 'filename="name2.txt"'
+ ),
+ "",
+ "value2",
+ "--boundary--",
+ ]
+ )
+ )
+ self.request = WSGIRequest(
+ {
+ "REQUEST_METHOD": "POST",
+ "CONTENT_TYPE": "multipart/form-data; boundary=boundary",
+ "CONTENT_LENGTH": len(payload),
+ "wsgi.input": payload,
+ }
+ )
+
+ def test_number_exceeded(self):
+ with self.settings(DATA_UPLOAD_MAX_NUMBER_FILES=1):
+ with self.assertRaisesMessage(TooManyFilesSent, TOO_MANY_FILES_MSG):
+ self.request._load_post_and_files()
+
+ def test_number_not_exceeded(self):
+ with self.settings(DATA_UPLOAD_MAX_NUMBER_FILES=2):
+ self.request._load_post_and_files()
+
+ def test_no_limit(self):
+ with self.settings(DATA_UPLOAD_MAX_NUMBER_FILES=None):
+ self.request._load_post_and_files()
+
+
class DataUploadMaxNumberOfFieldsFormPost(SimpleTestCase):
def setUp(self):
payload = FakePayload("\r\n".join(['a=1&a=2&a=3', '']))

View File

@ -1,322 +0,0 @@
From 6bb2e1ac607b1a399e1d7bd3650c04a586e6746e Mon Sep 17 00:00:00 2001
From: starlet-dx <15929766099@163.com>
Date: Tue, 16 May 2023 10:00:42 +0800
Subject: [PATCH 1/1] [3.2.x] Fixed CVE-2023-31047, Fixed #31710 -- Prevented
potential bypass of validation when uploading multiple files using one form
field.
Thanks Moataz Al-Sharida and nawaik for reports.
Co-authored-by: Shai Berger <shai@platonix.com>
Co-authored-by: nessita <124304+nessita@users.noreply.github.com>
Origin:
https://github.com/django/django/commit/eed53d0011622e70b936e203005f0e6f4ac48965
---
django/forms/widgets.py | 26 ++++++-
docs/topics/http/file-uploads.txt | 65 ++++++++++++++++--
.../forms_tests/field_tests/test_filefield.py | 68 ++++++++++++++++++-
.../widget_tests/test_clearablefileinput.py | 5 ++
.../widget_tests/test_fileinput.py | 44 ++++++++++++
5 files changed, 200 insertions(+), 8 deletions(-)
diff --git a/django/forms/widgets.py b/django/forms/widgets.py
index 1b1c143..8ef8255 100644
--- a/django/forms/widgets.py
+++ b/django/forms/widgets.py
@@ -378,16 +378,40 @@ class MultipleHiddenInput(HiddenInput):
class FileInput(Input):
input_type = 'file'
+ allow_multiple_selected = False
needs_multipart_form = True
template_name = 'django/forms/widgets/file.html'
+ def __init__(self, attrs=None):
+ if (
+ attrs is not None and
+ not self.allow_multiple_selected and
+ attrs.get("multiple", False)
+ ):
+ raise ValueError(
+ "%s doesn't support uploading multiple files."
+ % self.__class__.__qualname__
+ )
+ if self.allow_multiple_selected:
+ if attrs is None:
+ attrs = {"multiple": True}
+ else:
+ attrs.setdefault("multiple", True)
+ super().__init__(attrs)
+
def format_value(self, value):
"""File input never renders a value."""
return
def value_from_datadict(self, data, files, name):
"File widgets take data from FILES, not POST"
- return files.get(name)
+ getter = files.get
+ if self.allow_multiple_selected:
+ try:
+ getter = files.getlist
+ except AttributeError:
+ pass
+ return getter(name)
def value_omitted_from_data(self, data, files, name):
return name not in files
diff --git a/docs/topics/http/file-uploads.txt b/docs/topics/http/file-uploads.txt
index ca272d7..4388594 100644
--- a/docs/topics/http/file-uploads.txt
+++ b/docs/topics/http/file-uploads.txt
@@ -126,19 +126,54 @@ model::
form = UploadFileForm()
return render(request, 'upload.html', {'form': form})
+.. _uploading_multiple_files:
+
Uploading multiple files
------------------------
-If you want to upload multiple files using one form field, set the ``multiple``
-HTML attribute of field's widget:
+..
+ Tests in tests.forms_tests.field_tests.test_filefield.MultipleFileFieldTest
+ should be updated after any changes in the following snippets.
+
+If you want to upload multiple files using one form field, create a subclass
+of the field's widget and set the ``allow_multiple_selected`` attribute on it
+to ``True``.
+
+In order for such files to be all validated by your form (and have the value of
+the field include them all), you will also have to subclass ``FileField``. See
+below for an example.
+
+.. admonition:: Multiple file field
+
+ Django is likely to have a proper multiple file field support at some point
+ in the future.
.. code-block:: python
:caption: forms.py
from django import forms
+
+ class MultipleFileInput(forms.ClearableFileInput):
+ allow_multiple_selected = True
+
+
+ class MultipleFileField(forms.FileField):
+ def __init__(self, *args, **kwargs):
+ kwargs.setdefault("widget", MultipleFileInput())
+ super().__init__(*args, **kwargs)
+
+ def clean(self, data, initial=None):
+ single_file_clean = super().clean
+ if isinstance(data, (list, tuple)):
+ result = [single_file_clean(d, initial) for d in data]
+ else:
+ result = single_file_clean(data, initial)
+ return result
+
+
class FileFieldForm(forms.Form):
- file_field = forms.FileField(widget=forms.ClearableFileInput(attrs={'multiple': True}))
+ file_field = MultipleFileField()
Then override the ``post`` method of your
:class:`~django.views.generic.edit.FormView` subclass to handle multiple file
@@ -158,14 +193,32 @@ uploads:
def post(self, request, *args, **kwargs):
form_class = self.get_form_class()
form = self.get_form(form_class)
- files = request.FILES.getlist('file_field')
if form.is_valid():
- for f in files:
- ... # Do something with each file.
return self.form_valid(form)
else:
return self.form_invalid(form)
+ def form_valid(self, form):
+ files = form.cleaned_data["file_field"]
+ for f in files:
+ ... # Do something with each file.
+ return super().form_valid()
+
+.. warning::
+
+ This will allow you to handle multiple files at the form level only. Be
+ aware that you cannot use it to put multiple files on a single model
+ instance (in a single field), for example, even if the custom widget is used
+ with a form field related to a model ``FileField``.
+
+.. versionchanged:: 3.2.19
+
+ In previous versions, there was no support for the ``allow_multiple_selected``
+ class attribute, and users were advised to create the widget with the HTML
+ attribute ``multiple`` set through the ``attrs`` argument. However, this
+ caused validation of the form field to be applied only to the last file
+ submitted, which could have adverse security implications.
+
Upload Handlers
===============
diff --git a/tests/forms_tests/field_tests/test_filefield.py b/tests/forms_tests/field_tests/test_filefield.py
index 2db106e..b54febd 100644
--- a/tests/forms_tests/field_tests/test_filefield.py
+++ b/tests/forms_tests/field_tests/test_filefield.py
@@ -2,7 +2,8 @@ import pickle
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
-from django.forms import FileField
+from django.core.validators import validate_image_file_extension
+from django.forms import FileField, FileInput
from django.test import SimpleTestCase
@@ -83,3 +84,68 @@ class FileFieldTest(SimpleTestCase):
def test_file_picklable(self):
self.assertIsInstance(pickle.loads(pickle.dumps(FileField())), FileField)
+
+
+class MultipleFileInput(FileInput):
+ allow_multiple_selected = True
+
+
+class MultipleFileField(FileField):
+ def __init__(self, *args, **kwargs):
+ kwargs.setdefault("widget", MultipleFileInput())
+ super().__init__(*args, **kwargs)
+
+ def clean(self, data, initial=None):
+ single_file_clean = super().clean
+ if isinstance(data, (list, tuple)):
+ result = [single_file_clean(d, initial) for d in data]
+ else:
+ result = single_file_clean(data, initial)
+ return result
+
+
+class MultipleFileFieldTest(SimpleTestCase):
+ def test_file_multiple(self):
+ f = MultipleFileField()
+ files = [
+ SimpleUploadedFile("name1", b"Content 1"),
+ SimpleUploadedFile("name2", b"Content 2"),
+ ]
+ self.assertEqual(f.clean(files), files)
+
+ def test_file_multiple_empty(self):
+ f = MultipleFileField()
+ files = [
+ SimpleUploadedFile("empty", b""),
+ SimpleUploadedFile("nonempty", b"Some Content"),
+ ]
+ msg = "'The submitted file is empty.'"
+ with self.assertRaisesMessage(ValidationError, msg):
+ f.clean(files)
+ with self.assertRaisesMessage(ValidationError, msg):
+ f.clean(files[::-1])
+
+ def test_file_multiple_validation(self):
+ f = MultipleFileField(validators=[validate_image_file_extension])
+
+ good_files = [
+ SimpleUploadedFile("image1.jpg", b"fake JPEG"),
+ SimpleUploadedFile("image2.png", b"faux image"),
+ SimpleUploadedFile("image3.bmp", b"fraudulent bitmap"),
+ ]
+ self.assertEqual(f.clean(good_files), good_files)
+
+ evil_files = [
+ SimpleUploadedFile("image1.sh", b"#!/bin/bash -c 'echo pwned!'\n"),
+ SimpleUploadedFile("image2.png", b"faux image"),
+ SimpleUploadedFile("image3.jpg", b"fake JPEG"),
+ ]
+
+ evil_rotations = (
+ evil_files[i:] + evil_files[:i] # Rotate by i.
+ for i in range(len(evil_files))
+ )
+ msg = "File extension “sh” is not allowed. Allowed extensions are: "
+ for rotated_evil_files in evil_rotations:
+ with self.assertRaisesMessage(ValidationError, msg):
+ f.clean(rotated_evil_files)
diff --git a/tests/forms_tests/widget_tests/test_clearablefileinput.py b/tests/forms_tests/widget_tests/test_clearablefileinput.py
index dee44c4..6cf1476 100644
--- a/tests/forms_tests/widget_tests/test_clearablefileinput.py
+++ b/tests/forms_tests/widget_tests/test_clearablefileinput.py
@@ -176,3 +176,8 @@ class ClearableFileInputTest(WidgetTest):
self.assertIs(widget.value_omitted_from_data({}, {}, 'field'), True)
self.assertIs(widget.value_omitted_from_data({}, {'field': 'x'}, 'field'), False)
self.assertIs(widget.value_omitted_from_data({'field-clear': 'y'}, {}, 'field'), False)
+
+ def test_multiple_error(self):
+ msg = "ClearableFileInput doesn't support uploading multiple files."
+ with self.assertRaisesMessage(ValueError, msg):
+ ClearableFileInput(attrs={"multiple": True})
diff --git a/tests/forms_tests/widget_tests/test_fileinput.py b/tests/forms_tests/widget_tests/test_fileinput.py
index 8eec262..8068f70 100644
--- a/tests/forms_tests/widget_tests/test_fileinput.py
+++ b/tests/forms_tests/widget_tests/test_fileinput.py
@@ -1,4 +1,6 @@
+from django.core.files.uploadedfile import SimpleUploadedFile
from django.forms import FileInput
+from django.utils.datastructures import MultiValueDict
from .base import WidgetTest
@@ -24,3 +26,45 @@ class FileInputTest(WidgetTest):
# user to keep the existing, initial value.
self.assertIs(self.widget.use_required_attribute(None), True)
self.assertIs(self.widget.use_required_attribute('resume.txt'), False)
+
+ def test_multiple_error(self):
+ msg = "FileInput doesn't support uploading multiple files."
+ with self.assertRaisesMessage(ValueError, msg):
+ FileInput(attrs={"multiple": True})
+
+ def test_value_from_datadict_multiple(self):
+ class MultipleFileInput(FileInput):
+ allow_multiple_selected = True
+
+ file_1 = SimpleUploadedFile("something1.txt", b"content 1")
+ file_2 = SimpleUploadedFile("something2.txt", b"content 2")
+ # Uploading multiple files is allowed.
+ widget = MultipleFileInput(attrs={"multiple": True})
+ value = widget.value_from_datadict(
+ data={"name": "Test name"},
+ files=MultiValueDict({"myfile": [file_1, file_2]}),
+ name="myfile",
+ )
+ self.assertEqual(value, [file_1, file_2])
+ # Uploading multiple files is not allowed.
+ widget = FileInput()
+ value = widget.value_from_datadict(
+ data={"name": "Test name"},
+ files=MultiValueDict({"myfile": [file_1, file_2]}),
+ name="myfile",
+ )
+ self.assertEqual(value, file_2)
+
+ def test_multiple_default(self):
+ class MultipleFileInput(FileInput):
+ allow_multiple_selected = True
+
+ tests = [
+ (None, True),
+ ({"class": "myclass"}, True),
+ ({"multiple": False}, False),
+ ]
+ for attrs, expected in tests:
+ with self.subTest(attrs=attrs):
+ widget = MultipleFileInput(attrs=attrs)
+ self.assertIs(widget.attrs["multiple"], expected)
--
2.30.0

View File

@ -1,244 +0,0 @@
From 454f2fb93437f98917283336201b4048293f7582 Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak <felisiak.mariusz@gmail.com>
Date: Wed, 14 Jun 2023 12:23:06 +0200
Subject: [PATCH] [3.2.x] Fixed CVE-2023-36053 -- Prevented potential ReDoS in
EmailValidator and URLValidator.
Thanks Seokchan Yoon for reports.
---
django/core/validators.py | 7 ++++--
django/forms/fields.py | 3 +++
docs/ref/forms/fields.txt | 7 +++++-
docs/ref/validators.txt | 25 ++++++++++++++++++-
docs/releases/3.2.20.txt | 7 +++++-
.../field_tests/test_emailfield.py | 5 +++-
tests/forms_tests/tests/test_forms.py | 19 +++++++++-----
tests/validators/tests.py | 11 ++++++++
8 files changed, 72 insertions(+), 12 deletions(-)
diff --git a/django/core/validators.py b/django/core/validators.py
index 731ccf2d4690..b9b58dfa6176 100644
--- a/django/core/validators.py
+++ b/django/core/validators.py
@@ -93,6 +93,7 @@ class URLValidator(RegexValidator):
message = _('Enter a valid URL.')
schemes = ['http', 'https', 'ftp', 'ftps']
unsafe_chars = frozenset('\t\r\n')
+ max_length = 2048
def __init__(self, schemes=None, **kwargs):
super().__init__(**kwargs)
@@ -100,7 +101,7 @@ def __init__(self, schemes=None, **kwargs):
self.schemes = schemes
def __call__(self, value):
- if not isinstance(value, str):
+ if not isinstance(value, str) or len(value) > self.max_length:
raise ValidationError(self.message, code=self.code, params={'value': value})
if self.unsafe_chars.intersection(value):
raise ValidationError(self.message, code=self.code, params={'value': value})
@@ -210,7 +211,9 @@ def __init__(self, message=None, code=None, allowlist=None, *, whitelist=None):
self.domain_allowlist = allowlist
def __call__(self, value):
- if not value or '@' not in value:
+ # The maximum length of an email is 320 characters per RFC 3696
+ # section 3.
+ if not value or '@' not in value or len(value) > 320:
raise ValidationError(self.message, code=self.code, params={'value': value})
user_part, domain_part = value.rsplit('@', 1)
diff --git a/django/forms/fields.py b/django/forms/fields.py
index 0214d60c1cf1..8adb09e38294 100644
--- a/django/forms/fields.py
+++ b/django/forms/fields.py
@@ -540,6 +540,9 @@ class EmailField(CharField):
default_validators = [validators.validate_email]
def __init__(self, **kwargs):
+ # The default maximum length of an email is 320 characters per RFC 3696
+ # section 3.
+ kwargs.setdefault("max_length", 320)
super().__init__(strip=True, **kwargs)
diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt
index 9438214a28ce..5b485f215384 100644
--- a/docs/ref/forms/fields.txt
+++ b/docs/ref/forms/fields.txt
@@ -592,7 +592,12 @@ For each field, we describe the default widget used if you don't specify
* Error message keys: ``required``, ``invalid``
Has three optional arguments ``max_length``, ``min_length``, and
- ``empty_value`` which work just as they do for :class:`CharField`.
+ ``empty_value`` which work just as they do for :class:`CharField`. The
+ ``max_length`` argument defaults to 320 (see :rfc:`3696#section-3`).
+
+ .. versionchanged:: 3.2.20
+
+ The default value for ``max_length`` was changed to 320 characters.
``FileField``
-------------
diff --git a/docs/ref/validators.txt b/docs/ref/validators.txt
index 50761e5a425c..b22762b17b93 100644
--- a/docs/ref/validators.txt
+++ b/docs/ref/validators.txt
@@ -130,6 +130,11 @@ to, or in lieu of custom ``field.clean()`` methods.
:param code: If not ``None``, overrides :attr:`code`.
:param allowlist: If not ``None``, overrides :attr:`allowlist`.
+ An :class:`EmailValidator` ensures that a value looks like an email, and
+ raises a :exc:`~django.core.exceptions.ValidationError` with
+ :attr:`message` and :attr:`code` if it doesn't. Values longer than 320
+ characters are always considered invalid.
+
.. attribute:: message
The error message used by
@@ -158,13 +163,19 @@ to, or in lieu of custom ``field.clean()`` methods.
The undocumented ``domain_whitelist`` attribute is deprecated. Use
``domain_allowlist`` instead.
+ .. versionchanged:: 3.2.20
+
+ In older versions, values longer than 320 characters could be
+ considered valid.
+
``URLValidator``
----------------
.. class:: URLValidator(schemes=None, regex=None, message=None, code=None)
A :class:`RegexValidator` subclass that ensures a value looks like a URL,
- and raises an error code of ``'invalid'`` if it doesn't.
+ and raises an error code of ``'invalid'`` if it doesn't. Values longer than
+ :attr:`max_length` characters are always considered invalid.
Loopback addresses and reserved IP spaces are considered valid. Literal
IPv6 addresses (:rfc:`3986#section-3.2.2`) and Unicode domains are both
@@ -181,6 +192,18 @@ to, or in lieu of custom ``field.clean()`` methods.
.. _valid URI schemes: https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
+ .. attribute:: max_length
+
+ .. versionadded:: 3.2.20
+
+ The maximum length of values that could be considered valid. Defaults
+ to 2048 characters.
+
+ .. versionchanged:: 3.2.20
+
+ In older versions, values longer than 2048 characters could be
+ considered valid.
+
``validate_email``
------------------
diff --git a/tests/forms_tests/field_tests/test_emailfield.py b/tests/forms_tests/field_tests/test_emailfield.py
index 8b85e4dcc144..19d315205d7e 100644
--- a/tests/forms_tests/field_tests/test_emailfield.py
+++ b/tests/forms_tests/field_tests/test_emailfield.py
@@ -9,7 +9,10 @@ class EmailFieldTest(FormFieldAssertionsMixin, SimpleTestCase):
def test_emailfield_1(self):
f = EmailField()
- self.assertWidgetRendersTo(f, '<input type="email" name="f" id="id_f" required>')
+ self.assertEqual(f.max_length, 320)
+ self.assertWidgetRendersTo(
+ f, '<input type="email" name="f" id="id_f" maxlength="320" required>'
+ )
with self.assertRaisesMessage(ValidationError, "'This field is required.'"):
f.clean('')
with self.assertRaisesMessage(ValidationError, "'This field is required.'"):
diff --git a/tests/forms_tests/tests/test_forms.py b/tests/forms_tests/tests/test_forms.py
index 26f8ecafea44..82a32af403a0 100644
--- a/tests/forms_tests/tests/test_forms.py
+++ b/tests/forms_tests/tests/test_forms.py
@@ -422,11 +422,18 @@ class SignupForm(Form):
get_spam = BooleanField()
f = SignupForm(auto_id=False)
- self.assertHTMLEqual(str(f['email']), '<input type="email" name="email" required>')
+ self.assertHTMLEqual(
+ str(f["email"]),
+ '<input type="email" name="email" maxlength="320" required>',
+ )
self.assertHTMLEqual(str(f['get_spam']), '<input type="checkbox" name="get_spam" required>')
f = SignupForm({'email': 'test@example.com', 'get_spam': True}, auto_id=False)
- self.assertHTMLEqual(str(f['email']), '<input type="email" name="email" value="test@example.com" required>')
+ self.assertHTMLEqual(
+ str(f["email"]),
+ '<input type="email" name="email" maxlength="320" value="test@example.com" '
+ "required>",
+ )
self.assertHTMLEqual(
str(f['get_spam']),
'<input checked type="checkbox" name="get_spam" required>',
@@ -2824,7 +2831,7 @@ class Person(Form):
<option value="true">Yes</option>
<option value="false">No</option>
</select></li>
-<li><label for="id_email">Email:</label> <input type="email" name="email" id="id_email"></li>
+<li><label for="id_email">Email:</label> <input type="email" name="email" id="id_email" maxlength="320"></li>
<li class="required error"><ul class="errorlist"><li>This field is required.</li></ul>
<label class="required" for="id_age">Age:</label> <input type="number" name="age" id="id_age" required></li>"""
)
@@ -2840,7 +2847,7 @@ class Person(Form):
<option value="true">Yes</option>
<option value="false">No</option>
</select></p>
-<p><label for="id_email">Email:</label> <input type="email" name="email" id="id_email"></p>
+<p><label for="id_email">Email:</label> <input type="email" name="email" id="id_email" maxlength="320"></p>
<ul class="errorlist"><li>This field is required.</li></ul>
<p class="required error"><label class="required" for="id_age">Age:</label>
<input type="number" name="age" id="id_age" required></p>"""
@@ -2859,7 +2866,7 @@ class Person(Form):
<option value="false">No</option>
</select></td></tr>
<tr><th><label for="id_email">Email:</label></th><td>
-<input type="email" name="email" id="id_email"></td></tr>
+<input type="email" name="email" id="id_email" maxlength="320"></td></tr>
<tr class="required error"><th><label class="required" for="id_age">Age:</label></th>
<td><ul class="errorlist"><li>This field is required.</li></ul>
<input type="number" name="age" id="id_age" required></td></tr>"""
@@ -3489,7 +3496,7 @@ class CommentForm(Form):
f = CommentForm(data, auto_id=False, error_class=DivErrorList)
self.assertHTMLEqual(f.as_p(), """<p>Name: <input type="text" name="name" maxlength="50"></p>
<div class="errorlist"><div class="error">Enter a valid email address.</div></div>
-<p>Email: <input type="email" name="email" value="invalid" required></p>
+<p>Email: <input type="email" name="email" value="invalid" maxlength="320" required></p>
<div class="errorlist"><div class="error">This field is required.</div></div>
<p>Comment: <input type="text" name="comment" required></p>""")
diff --git a/tests/validators/tests.py b/tests/validators/tests.py
index e39d0e3a1cef..1065727a974e 100644
--- a/tests/validators/tests.py
+++ b/tests/validators/tests.py
@@ -59,6 +59,7 @@
(validate_email, 'example@atm.%s' % ('a' * 64), ValidationError),
(validate_email, 'example@%s.atm.%s' % ('b' * 64, 'a' * 63), ValidationError),
+ (validate_email, "example@%scom" % (("a" * 63 + ".") * 100), ValidationError),
(validate_email, None, ValidationError),
(validate_email, '', ValidationError),
(validate_email, 'abc', ValidationError),
@@ -246,6 +247,16 @@
(URLValidator(), None, ValidationError),
(URLValidator(), 56, ValidationError),
(URLValidator(), 'no_scheme', ValidationError),
+ (
+ URLValidator(),
+ "http://example." + ("a" * 63 + ".") * 1000 + "com",
+ ValidationError,
+ ),
+ (
+ URLValidator(),
+ "http://userid:password" + "d" * 2000 + "@example.aaaaaaaaaaaaa.com",
+ None,
+ ),
# Newlines and tabs are not accepted.
(URLValidator(), 'http://www.djangoproject.com/\n', ValidationError),
(URLValidator(), 'http://[::ffff:192.9.5.5]\n', ValidationError),

View File

@ -1,83 +0,0 @@
From 6f030b1149bd8fa4ba90452e77cb3edc095ce54e Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak <felisiak.mariusz@gmail.com>
Date: Tue, 22 Aug 2023 08:53:03 +0200
Subject: [PATCH] [3.2.x] Fixed CVE-2023-41164 -- Fixed potential DoS in
django.utils.encoding.uri_to_iri().
Thanks MProgrammer (https://hackerone.com/mprogrammer) for the report.
Origin: https://github.com/django/django/commit/6f030b1149bd8fa4ba90452e77cb3edc095ce54e
Co-authored-by: nessita <124304+nessita@users.noreply.github.com>
---
django/utils/encoding.py | 6 ++++--
docs/releases/3.2.21.txt | 7 ++++++-
tests/utils_tests/test_encoding.py | 21 ++++++++++++++++++++-
3 files changed, 30 insertions(+), 4 deletions(-)
diff --git a/django/utils/encoding.py b/django/utils/encoding.py
index e1ebacef4705..c5c4463b1c22 100644
--- a/django/utils/encoding.py
+++ b/django/utils/encoding.py
@@ -229,6 +229,7 @@ def repercent_broken_unicode(path):
repercent-encode any octet produced that is not part of a strictly legal
UTF-8 octet sequence.
"""
+ changed_parts = []
while True:
try:
path.decode()
@@ -236,9 +237,10 @@ def repercent_broken_unicode(path):
# CVE-2019-14235: A recursion shouldn't be used since the exception
# handling uses massive amounts of memory
repercent = quote(path[e.start:e.end], safe=b"/#%[]=:;$&()+,!?*@'~")
- path = path[:e.start] + repercent.encode() + path[e.end:]
+ changed_parts.append(path[:e.start] + repercent.encode())
+ path = path[e.end:]
else:
- return path
+ return b"".join(changed_parts) + path
def filepath_to_uri(path):
diff --git a/tests/utils_tests/test_encoding.py b/tests/utils_tests/test_encoding.py
index 36f2d8665f3c..42779050cb3a 100644
--- a/tests/utils_tests/test_encoding.py
+++ b/tests/utils_tests/test_encoding.py
@@ -1,9 +1,10 @@
import datetime
+import inspect
import sys
import unittest
from pathlib import Path
from unittest import mock
-from urllib.parse import quote_plus
+from urllib.parse import quote, quote_plus
from django.test import SimpleTestCase
from django.utils.encoding import (
@@ -101,6 +102,24 @@ def test_repercent_broken_unicode_recursion_error(self):
except RecursionError:
self.fail('Unexpected RecursionError raised.')
+ def test_repercent_broken_unicode_small_fragments(self):
+ data = b"test\xfctest\xfctest\xfc"
+ decoded_paths = []
+
+ def mock_quote(*args, **kwargs):
+ # The second frame is the call to repercent_broken_unicode().
+ decoded_paths.append(inspect.currentframe().f_back.f_locals["path"])
+ return quote(*args, **kwargs)
+
+ with mock.patch("django.utils.encoding.quote", mock_quote):
+ self.assertEqual(repercent_broken_unicode(data), b"test%FCtest%FCtest%FC")
+
+ # decode() is called on smaller fragment of the path each time.
+ self.assertEqual(
+ decoded_paths,
+ [b"test\xfctest\xfctest\xfc", b"test\xfctest\xfc", b"test\xfc"],
+ )
+
class TestRFC3987IEncodingUtils(unittest.TestCase):

View File

@ -1,168 +0,0 @@
From ccdade1a0262537868d7ca64374de3d957ca50c5 Mon Sep 17 00:00:00 2001
From: Natalia <124304+nessita@users.noreply.github.com>
Date: Tue, 19 Sep 2023 09:51:48 -0300
Subject: [PATCH] [3.2.x] Fixed CVE-2023-43665 -- Mitigated potential DoS in
django.utils.text.Truncator when truncating HTML text.
Thanks Wenchao Li of Alibaba Group for the report.
Origin:
https://github.com/django/django/commit/ccdade1a0262537868d7ca64374de3d957ca50c5
---
django/utils/text.py | 18 ++++++++++++++++-
docs/ref/templates/builtins.txt | 20 +++++++++++++++++++
tests/utils_tests/test_text.py | 35 ++++++++++++++++++++++++---------
3 files changed, 63 insertions(+), 10 deletions(-)
diff --git a/django/utils/text.py b/django/utils/text.py
index baa44f2..83e258f 100644
--- a/django/utils/text.py
+++ b/django/utils/text.py
@@ -60,7 +60,14 @@ def wrap(text, width):
class Truncator(SimpleLazyObject):
"""
An object used to truncate text, either by characters or words.
+
+ When truncating HTML text (either chars or words), input will be limited to
+ at most `MAX_LENGTH_HTML` characters.
"""
+
+ # 5 million characters are approximately 4000 text pages or 3 web pages.
+ MAX_LENGTH_HTML = 5_000_000
+
def __init__(self, text):
super().__init__(lambda: str(text))
@@ -157,6 +164,11 @@ class Truncator(SimpleLazyObject):
if words and length <= 0:
return ''
+ size_limited = False
+ if len(text) > self.MAX_LENGTH_HTML:
+ text = text[: self.MAX_LENGTH_HTML]
+ size_limited = True
+
html4_singlets = (
'br', 'col', 'link', 'base', 'img',
'param', 'area', 'hr', 'input'
@@ -206,10 +218,14 @@ class Truncator(SimpleLazyObject):
# Add it to the start of the open tags list
open_tags.insert(0, tagname)
+ truncate_text = self.add_truncation_text("", truncate)
+
if current_len <= length:
+ if size_limited and truncate_text:
+ text += truncate_text
return text
+
out = text[:end_text_pos]
- truncate_text = self.add_truncation_text('', truncate)
if truncate_text:
out += truncate_text
# Close any tags still open
diff --git a/docs/ref/templates/builtins.txt b/docs/ref/templates/builtins.txt
index 22509a2..a6fd971 100644
--- a/docs/ref/templates/builtins.txt
+++ b/docs/ref/templates/builtins.txt
@@ -2348,6 +2348,16 @@ If ``value`` is ``"<p>Joel is a slug</p>"``, the output will be
Newlines in the HTML content will be preserved.
+.. admonition:: Size of input string
+
+ Processing large, potentially malformed HTML strings can be
+ resource-intensive and impact service performance. ``truncatechars_html``
+ limits input to the first five million characters.
+
+.. versionchanged:: 3.2.22
+
+ In older versions, strings over five million characters were processed.
+
.. templatefilter:: truncatewords
``truncatewords``
@@ -2386,6 +2396,16 @@ If ``value`` is ``"<p>Joel is a slug</p>"``, the output will be
Newlines in the HTML content will be preserved.
+.. admonition:: Size of input string
+
+ Processing large, potentially malformed HTML strings can be
+ resource-intensive and impact service performance. ``truncatewords_html``
+ limits input to the first five million characters.
+
+.. versionchanged:: 3.2.22
+
+ In older versions, strings over five million characters were processed.
+
.. templatefilter:: unordered_list
``unordered_list``
diff --git a/tests/utils_tests/test_text.py b/tests/utils_tests/test_text.py
index d2a94fc..0a6f0bc 100644
--- a/tests/utils_tests/test_text.py
+++ b/tests/utils_tests/test_text.py
@@ -1,5 +1,6 @@
import json
import sys
+from unittest.mock import patch
from django.core.exceptions import SuspiciousFileOperation
from django.test import SimpleTestCase, ignore_warnings
@@ -90,11 +91,17 @@ class TestUtilsText(SimpleTestCase):
# lazy strings are handled correctly
self.assertEqual(text.Truncator(lazystr('The quick brown fox')).chars(10), 'The quick…')
- def test_truncate_chars_html(self):
+ @patch("django.utils.text.Truncator.MAX_LENGTH_HTML", 10_000)
+ def test_truncate_chars_html_size_limit(self):
+ max_len = text.Truncator.MAX_LENGTH_HTML
+ bigger_len = text.Truncator.MAX_LENGTH_HTML + 1
+ valid_html = "<p>Joel is a slug</p>" # 14 chars
perf_test_values = [
- (('</a' + '\t' * 50000) + '//>', None),
- ('&' * 50000, '&' * 9 + '…'),
- ('_X<<<<<<<<<<<>', None),
+ ("</a" + "\t" * (max_len - 6) + "//>", None),
+ ("</p" + "\t" * bigger_len + "//>", "</p" + "\t" * 6 + "…"),
+ ("&" * bigger_len, "&" * 9 + "…"),
+ ("_X<<<<<<<<<<<>", None),
+ (valid_html * bigger_len, "<p>Joel is a…</p>"), # 10 chars
]
for value, expected in perf_test_values:
with self.subTest(value=value):
@@ -152,15 +159,25 @@ class TestUtilsText(SimpleTestCase):
truncator = text.Truncator('<p>I &lt;3 python, what about you?</p>')
self.assertEqual('<p>I &lt;3 python,…</p>', truncator.words(3, html=True))
+ @patch("django.utils.text.Truncator.MAX_LENGTH_HTML", 10_000)
+ def test_truncate_words_html_size_limit(self):
+ max_len = text.Truncator.MAX_LENGTH_HTML
+ bigger_len = text.Truncator.MAX_LENGTH_HTML + 1
+ valid_html = "<p>Joel is a slug</p>" # 4 words
perf_test_values = [
- ('</a' + '\t' * 50000) + '//>',
- '&' * 50000,
- '_X<<<<<<<<<<<>',
+ ("</a" + "\t" * (max_len - 6) + "//>", None),
+ ("</p" + "\t" * bigger_len + "//>", "</p" + "\t" * (max_len - 3) + "…"),
+ ("&" * max_len, None), # no change
+ ("&" * bigger_len, "&" * max_len + "…"),
+ ("_X<<<<<<<<<<<>", None),
+ (valid_html * bigger_len, valid_html * 12 + "<p>Joel is…</p>"), # 50 words
]
- for value in perf_test_values:
+ for value, expected in perf_test_values:
with self.subTest(value=value):
truncator = text.Truncator(value)
- self.assertEqual(value, truncator.words(50, html=True))
+ self.assertEqual(
+ expected if expected else value, truncator.words(50, html=True)
+ )
def test_wrap(self):
digits = '1234 67 9'
--
2.30.0

View File

@ -1,62 +0,0 @@
From f9a7fb8466a7ba4857eaf930099b5258f3eafb2b Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak <felisiak.mariusz@gmail.com>
Date: Tue, 17 Oct 2023 11:48:32 +0200
Subject: [PATCH] [3.2.x] Fixed CVE-2023-46695 -- Fixed potential DoS in
UsernameField on Windows.
Thanks MProgrammer (https://hackerone.com/mprogrammer) for the report.
---
django/contrib/auth/forms.py | 10 +++++++++-
tests/auth_tests/test_forms.py | 8 +++++++-
2 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py
index 20d8922..fb7cfda 100644
--- a/django/contrib/auth/forms.py
+++ b/django/contrib/auth/forms.py
@@ -62,7 +62,15 @@ class ReadOnlyPasswordHashField(forms.Field):
class UsernameField(forms.CharField):
def to_python(self, value):
- return unicodedata.normalize('NFKC', super().to_python(value))
+ value = super().to_python(value)
+ if self.max_length is not None and len(value) > self.max_length:
+ # Normalization can increase the string length (e.g.
+ # "ff" -> "ff", "½" -> "12") but cannot reduce it, so there is no
+ # point in normalizing invalid data. Moreover, Unicode
+ # normalization is very slow on Windows and can be a DoS attack
+ # vector.
+ return value
+ return unicodedata.normalize("NFKC", value)
def widget_attrs(self, widget):
return {
diff --git a/tests/auth_tests/test_forms.py b/tests/auth_tests/test_forms.py
index 7a731be..c0e1975 100644
--- a/tests/auth_tests/test_forms.py
+++ b/tests/auth_tests/test_forms.py
@@ -5,7 +5,7 @@ from unittest import mock
from django.contrib.auth.forms import (
AdminPasswordChangeForm, AuthenticationForm, PasswordChangeForm,
PasswordResetForm, ReadOnlyPasswordHashField, ReadOnlyPasswordHashWidget,
- SetPasswordForm, UserChangeForm, UserCreationForm,
+ SetPasswordForm, UserChangeForm, UserCreationForm, UsernameField,
)
from django.contrib.auth.models import User
from django.contrib.auth.signals import user_login_failed
@@ -132,6 +132,12 @@ class UserCreationFormTest(TestDataMixin, TestCase):
self.assertNotEqual(user.username, ohm_username)
self.assertEqual(user.username, 'testΩ') # U+03A9 GREEK CAPITAL LETTER OMEGA
+ def test_invalid_username_no_normalize(self):
+ field = UsernameField(max_length=254)
+ # Usernames are not normalized if they are too long.
+ self.assertEqual(field.to_python("½" * 255), "½" * 255)
+ self.assertEqual(field.to_python("ff" * 254), "ff" * 254)
+
def test_duplicate_normalized_unicode(self):
"""
To prevent almost identical usernames, visually identical but differing
--
2.30.0

View File

@ -1,204 +0,0 @@
From c1171ffbd570db90ca206c30f8e2b9f691243820 Mon Sep 17 00:00:00 2001
From: Adam Johnson <me@adamj.eu>
Date: Mon, 22 Jan 2024 13:21:13 +0000
Subject: [PATCH] [3.2.x] Fixed CVE-2024-24680 -- Mitigated potential DoS in
intcomma template filter.
Thanks Seokchan Yoon for the report.
Co-authored-by: Mariusz Felisiak <felisiak.mariusz@gmail.com>
Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>
Co-authored-by: Shai Berger <shai@platonix.com>
---
.../contrib/humanize/templatetags/humanize.py | 13 +-
tests/humanize_tests/tests.py | 140 ++++++++++++++++--
2 files changed, 135 insertions(+), 18 deletions(-)
diff --git a/django/contrib/humanize/templatetags/humanize.py b/django/contrib/humanize/templatetags/humanize.py
index 753a0d9..238aaf2 100644
--- a/django/contrib/humanize/templatetags/humanize.py
+++ b/django/contrib/humanize/templatetags/humanize.py
@@ -70,12 +70,13 @@ def intcomma(value, use_l10n=True):
return intcomma(value, False)
else:
return number_format(value, use_l10n=True, force_grouping=True)
- orig = str(value)
- new = re.sub(r"^(-?\d+)(\d{3})", r'\g<1>,\g<2>', orig)
- if orig == new:
- return new
- else:
- return intcomma(new, use_l10n)
+ result = str(value)
+ match = re.match(r"-?\d+", result)
+ if match:
+ prefix = match[0]
+ prefix_with_commas = re.sub(r"\d{3}", r"\g<0>,", prefix[::-1])[::-1]
+ result = prefix_with_commas + result[len(prefix) :]
+ return result
# A tuple of standard large number to their converters
diff --git a/tests/humanize_tests/tests.py b/tests/humanize_tests/tests.py
index a0d16bb..3c22787 100644
--- a/tests/humanize_tests/tests.py
+++ b/tests/humanize_tests/tests.py
@@ -66,28 +66,144 @@ class HumanizeTests(SimpleTestCase):
def test_intcomma(self):
test_list = (
- 100, 1000, 10123, 10311, 1000000, 1234567.25, '100', '1000',
- '10123', '10311', '1000000', '1234567.1234567',
- Decimal('1234567.1234567'), None,
+ 100,
+ -100,
+ 1000,
+ -1000,
+ 10123,
+ -10123,
+ 10311,
+ -10311,
+ 1000000,
+ -1000000,
+ 1234567.25,
+ -1234567.25,
+ "100",
+ "-100",
+ "1000",
+ "-1000",
+ "10123",
+ "-10123",
+ "10311",
+ "-10311",
+ "1000000",
+ "-1000000",
+ "1234567.1234567",
+ "-1234567.1234567",
+ Decimal("1234567.1234567"),
+ Decimal("-1234567.1234567"),
+ None,
+ "",
+ "-",
+ ".",
+ "-.",
+ "the quick brown fox jumped over the lazy dog",
)
result_list = (
- '100', '1,000', '10,123', '10,311', '1,000,000', '1,234,567.25',
- '100', '1,000', '10,123', '10,311', '1,000,000', '1,234,567.1234567',
- '1,234,567.1234567', None,
+ "100",
+ "-100",
+ "1,000",
+ "-1,000",
+ "10,123",
+ "-10,123",
+ "10,311",
+ "-10,311",
+ "1,000,000",
+ "-1,000,000",
+ "1,234,567.25",
+ "-1,234,567.25",
+ "100",
+ "-100",
+ "1,000",
+ "-1,000",
+ "10,123",
+ "-10,123",
+ "10,311",
+ "-10,311",
+ "1,000,000",
+ "-1,000,000",
+ "1,234,567.1234567",
+ "-1,234,567.1234567",
+ "1,234,567.1234567",
+ "-1,234,567.1234567",
+ None,
+ "1,234,567",
+ "-1,234,567",
+ ",,.",
+ "-,,.",
+ "the quick brown fox jumped over the lazy dog",
)
with translation.override('en'):
self.humanize_tester(test_list, result_list, 'intcomma')
def test_l10n_intcomma(self):
test_list = (
- 100, 1000, 10123, 10311, 1000000, 1234567.25, '100', '1000',
- '10123', '10311', '1000000', '1234567.1234567',
- Decimal('1234567.1234567'), None,
+ 100,
+ -100,
+ 1000,
+ -1000,
+ 10123,
+ -10123,
+ 10311,
+ -10311,
+ 1000000,
+ -1000000,
+ 1234567.25,
+ -1234567.25,
+ "100",
+ "-100",
+ "1000",
+ "-1000",
+ "10123",
+ "-10123",
+ "10311",
+ "-10311",
+ "1000000",
+ "-1000000",
+ "1234567.1234567",
+ "-1234567.1234567",
+ Decimal("1234567.1234567"),
+ -Decimal("1234567.1234567"),
+ None,
+ "",
+ "-",
+ ".",
+ "-.",
+ "the quick brown fox jumped over the lazy dog",
)
result_list = (
- '100', '1,000', '10,123', '10,311', '1,000,000', '1,234,567.25',
- '100', '1,000', '10,123', '10,311', '1,000,000', '1,234,567.1234567',
- '1,234,567.1234567', None,
+ "100",
+ "-100",
+ "1,000",
+ "-1,000",
+ "10,123",
+ "-10,123",
+ "10,311",
+ "-10,311",
+ "1,000,000",
+ "-1,000,000",
+ "1,234,567.25",
+ "-1,234,567.25",
+ "100",
+ "-100",
+ "1,000",
+ "-1,000",
+ "10,123",
+ "-10,123",
+ "10,311",
+ "-10,311",
+ "1,000,000",
+ "-1,000,000",
+ "1,234,567.1234567",
+ "-1,234,567.1234567",
+ "1,234,567.1234567",
+ "-1,234,567.1234567",
+ None,
+ "1,234,567",
+ "-1,234,567",
+ ",,.",
+ "-,,.",
+ "the quick brown fox jumped over the lazy dog",
)
with self.settings(USE_L10N=True, USE_THOUSAND_SEPARATOR=False):
with translation.override('en'):
--
2.33.0

View File

@ -1,122 +0,0 @@
From 072963e4c4d0b3a7a8c5412bc0c7d27d1a9c3521 Mon Sep 17 00:00:00 2001
From: Shai Berger <shai@platonix.com>
Date: Mon, 19 Feb 2024 13:56:37 +0100
Subject: [PATCH] [3.2.x] Fixed CVE-2024-27351 -- Prevented potential ReDoS in
Truncator.words().
Thanks Seokchan Yoon for the report.
Co-Authored-By: Mariusz Felisiak <felisiak.mariusz@gmail.com>
---
django/utils/text.py | 57 ++++++++++++++++++++++++++++++++--
tests/utils_tests/test_text.py | 26 ++++++++++++++++
2 files changed, 81 insertions(+), 2 deletions(-)
diff --git a/django/utils/text.py b/django/utils/text.py
index 83e258f..88da9a2 100644
--- a/django/utils/text.py
+++ b/django/utils/text.py
@@ -18,8 +18,61 @@ def capfirst(x):
return x and str(x)[0].upper() + str(x)[1:]
-# Set up regular expressions
-re_words = _lazy_re_compile(r'<[^>]+?>|([^<>\s]+)', re.S)
+# ----- Begin security-related performance workaround -----
+
+# We used to have, below
+#
+# re_words = _lazy_re_compile(r"<[^>]+?>|([^<>\s]+)", re.S)
+#
+# But it was shown that this regex, in the way we use it here, has some
+# catastrophic edge-case performance features. Namely, when it is applied to
+# text with only open brackets "<<<...". The class below provides the services
+# and correct answers for the use cases, but in these edge cases does it much
+# faster.
+re_notag = _lazy_re_compile(r"([^<>\s]+)", re.S)
+re_prt = _lazy_re_compile(r"<|([^<>\s]+)", re.S)
+
+
+class WordsRegex:
+ @staticmethod
+ def search(text, pos):
+ # Look for "<" or a non-tag word.
+ partial = re_prt.search(text, pos)
+ if partial is None or partial[1] is not None:
+ return partial
+
+ # "<" was found, look for a closing ">".
+ end = text.find(">", partial.end(0))
+ if end < 0:
+ # ">" cannot be found, look for a word.
+ return re_notag.search(text, pos + 1)
+ else:
+ # "<" followed by a ">" was found -- fake a match.
+ end += 1
+ return FakeMatch(text[partial.start(0): end], end)
+
+
+class FakeMatch:
+ __slots__ = ["_text", "_end"]
+
+ def end(self, group=0):
+ assert group == 0, "This specific object takes only group=0"
+ return self._end
+
+ def __getitem__(self, group):
+ if group == 1:
+ return None
+ assert group == 0, "This specific object takes only group in {0,1}"
+ return self._text
+
+ def __init__(self, text, end):
+ self._text, self._end = text, end
+
+
+# ----- End security-related performance workaround -----
+
+# Set up regular expressions.
+re_words = WordsRegex
re_chars = _lazy_re_compile(r'<[^>]+?>|(.)', re.S)
re_tag = _lazy_re_compile(r'<(/)?(\S+?)(?:(\s*/)|\s.*?)?>', re.S)
re_newlines = _lazy_re_compile(r'\r\n|\r') # Used in normalize_newlines
diff --git a/tests/utils_tests/test_text.py b/tests/utils_tests/test_text.py
index 0a6f0bc..758919c 100644
--- a/tests/utils_tests/test_text.py
+++ b/tests/utils_tests/test_text.py
@@ -159,6 +159,32 @@ class TestUtilsText(SimpleTestCase):
truncator = text.Truncator('<p>I &lt;3 python, what about you?</p>')
self.assertEqual('<p>I &lt;3 python,…</p>', truncator.words(3, html=True))
+ # Only open brackets.
+ test = "<" * 60_000
+ truncator = text.Truncator(test)
+ self.assertEqual(truncator.words(1, html=True), test)
+
+ # Tags with special chars in attrs.
+ truncator = text.Truncator(
+ """<i style="margin: 5%; font: *;">Hello, my dear lady!</i>"""
+ )
+ self.assertEqual(
+ """<i style="margin: 5%; font: *;">Hello, my dear…</i>""",
+ truncator.words(3, html=True),
+ )
+
+ # Tags with special non-latin chars in attrs.
+ truncator = text.Truncator("""<p data-x="א">Hello, my dear lady!</p>""")
+ self.assertEqual(
+ """<p data-x="א">Hello, my dear…</p>""",
+ truncator.words(3, html=True),
+ )
+
+ # Misplaced brackets.
+ truncator = text.Truncator("hello >< world")
+ self.assertEqual(truncator.words(1, html=True), "hello…")
+ self.assertEqual(truncator.words(2, html=True), "hello >< world")
+
@patch("django.utils.text.Truncator.MAX_LENGTH_HTML", 10_000)
def test_truncate_words_html_size_limit(self):
max_len = text.Truncator.MAX_LENGTH_HTML
--
2.33.0

View File

@ -1,74 +0,0 @@
From 8c5a1dfe34ea52cc2af21064a8654bfaa8b7a012 Mon Sep 17 00:00:00 2001
From: Carlton Gibson <carlton.gibson@noumenal.es>
Date: Wed, 27 Jul 2022 10:27:42 +0200
Subject: [PATCH] [3.2.x] Fixed CVE-2022-36359: Escaped filename in
Content-Disposition header.
Thanks to Motoyasu Saburi for the report.
---
django/http/response.py | 4 +++-
docs/releases/3.2.15.txt | 8 ++++++-
tests/responses/test_fileresponse.py | 35 ++++++++++++++++++++++++++++
3 files changed, 45 insertions(+), 2 deletions(-)
diff --git a/django/http/response.py b/django/http/response.py
index 1c22edaff3..73f87d7bda 100644
--- a/django/http/response.py
+++ b/django/http/response.py
@@ -485,7 +485,9 @@ class FileResponse(StreamingHttpResponse):
disposition = 'attachment' if self.as_attachment else 'inline'
try:
filename.encode('ascii')
- file_expr = 'filename="{}"'.format(filename)
+ file_expr = 'filename="{}"'.format(
+ filename.replace('\\', '\\\\').replace('"', r'\"')
+ )
except UnicodeEncodeError:
file_expr = "filename*=utf-8''{}".format(quote(filename))
self.headers['Content-Disposition'] = '{}; {}'.format(disposition, file_expr)
diff --git a/tests/responses/test_fileresponse.py b/tests/responses/test_fileresponse.py
index 46d407bdf5..b4ef82ef3e 100644
--- a/tests/responses/test_fileresponse.py
+++ b/tests/responses/test_fileresponse.py
@@ -89,3 +89,38 @@ class FileResponseTests(SimpleTestCase):
response.headers['Content-Disposition'],
"attachment; filename*=utf-8''%E7%A5%9D%E6%82%A8%E5%B9%B3%E5%AE%89.odt"
)
+
+ def test_content_disposition_escaping(self):
+ # fmt: off
+ tests = [
+ (
+ 'multi-part-one";\" dummy".txt',
+ r"multi-part-one\";\" dummy\".txt"
+ ),
+ ]
+ # fmt: on
+ # Non-escape sequence backslashes are path segments on Windows, and are
+ # eliminated by an os.path.basename() check in FileResponse.
+ if sys.platform != "win32":
+ # fmt: off
+ tests += [
+ (
+ 'multi-part-one\\";\" dummy".txt',
+ r"multi-part-one\\\";\" dummy\".txt"
+ ),
+ (
+ 'multi-part-one\\";\\\" dummy".txt',
+ r"multi-part-one\\\";\\\" dummy\".txt"
+ )
+ ]
+ # fmt: on
+ for filename, escaped in tests:
+ with self.subTest(filename=filename, escaped=escaped):
+ response = FileResponse(
+ io.BytesIO(b"binary content"), filename=filename, as_attachment=True
+ )
+ response.close()
+ self.assertEqual(
+ response.headers["Content-Disposition"],
+ f'attachment; filename="{escaped}"',
+ )
--
2.36.1

View File

@ -1,28 +1,11 @@
%global _empty_manifest_terminate_build 0 %global _empty_manifest_terminate_build 0
Name: python-django Name: python-django
Version: 3.2.12 Version: 4.2.15
Release: 10 Release: 1
Summary: A high-level Python Web framework that encourages rapid development and clean, pragmatic design. Summary: A high-level Python Web framework that encourages rapid development and clean, pragmatic design.
License: Apache-2.0 and Python-2.0 and BSD-3-Clause License: Apache-2.0 and Python-2.0 and BSD-3-Clause
URL: https://www.djangoproject.com/ URL: https://www.djangoproject.com/
Source0: https://github.com/django/django/archive/refs/tags/%{version}.tar.gz Source0: https://files.pythonhosted.org/packages/source/d/Django/Django-%{version}.tar.gz
#https://github.com/django/django/commit/a9010fe5555e6086a9d9ae50069579400ef0685e
Patch0: CVE-2022-34265.patch
Patch1: backport-CVE-2022-36359.patch
Patch2: CVE-2023-23969.patch
Patch3: CVE-2023-24580.patch
Patch4: CVE-2023-31047.patch
Patch5: CVE-2023-36053.patch
Patch6: CVE-2023-41164.patch
# https://github.com/django/django/commit/ccdade1a0262537868d7ca64374de3d957ca50c5
Patch7: CVE-2023-43665.patch
# https://github.com/django/django/commit/f9a7fb8466a7ba4857eaf930099b5258f3eafb2b
Patch8: CVE-2023-46695.patch
# https://github.com/django/django/commit/c1171ffbd570db90ca206c30f8e2b9f691243820
Patch9: CVE-2024-24680.patch
# https://github.com/django/django/commit/072963e4c4d0b3a7a8c5412bc0c7d27d1a9c3521
Patch10: CVE-2024-27351.patch
BuildArch: noarch BuildArch: noarch
%description %description
@ -49,7 +32,7 @@ Provides: python3-Django-doc
Development documents and examples for Django Development documents and examples for Django
%prep %prep
%autosetup -n django-%{version} -p1 %autosetup -n Django-%{version} -p1
%build %build
%py3_build %py3_build
@ -89,6 +72,19 @@ mv %{buildroot}/doclist.lst .
%{_docdir}/* %{_docdir}/*
%changelog %changelog
* Thu Aug 08 2024 yaoxin <yao_xin001@hoperun.com> - 4.2.15-1
- Update to 4.2.15
* CVE-2024-41989: Memory exhaustion in ``django.utils.numberformat.floatformat()``
* CVE-2024-41990: Potential denial-of-service vulnerability in ``django.utils.html.urlize()``
* CVE-2024-41991: Potential denial-of-service vulnerability in ``django.utils.html.urlize()`` and ``AdminURLFieldWidget``
* CVE-2024-42005: Potential SQL injection in ``QuerySet.values()`` and ``values_list()``
* Fixed a regression in Django 4.2.14 that caused a crash in ``LocaleMiddleware`` when processing a language code over 500 characters
* CVE-2024-38875: Potential denial-of-service vulnerability in django.utils.html.urlize()
* CVE-2024-39329: Username enumeration through timing difference for users with unusable passwords
* CVE-2024-39330: Potential directory-traversal via Storage.save()
* CVE-2024-39614: Potential denial-of-service vulnerability in get_supported_language_variant()
* Fixed a crash in Django 4.2 when validating email max line lengths with content decoded using the surrogateescape error handling scheme
* Tue Mar 05 2024 yaoxin <yao_xin001@hoperun.com> - 3.2.12-10 * Tue Mar 05 2024 yaoxin <yao_xin001@hoperun.com> - 3.2.12-10
- Fix CVE-2024-27351 - Fix CVE-2024-27351