From 7190b38b8d2569e8d7a9f65e93d08ff44308fc81 Mon Sep 17 00:00:00 2001
From: Carlton Gibson
Date: Tue, 4 Oct 2022 09:36:16 +0200
Subject: [PATCH 01/44] [3.2.x] Post-release version bump.
---
django/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/django/__init__.py b/django/__init__.py
index e053a23cb3a..ad16ceec43f 100644
--- a/django/__init__.py
+++ b/django/__init__.py
@@ -1,6 +1,6 @@
from django.utils.version import get_version
-VERSION = (3, 2, 16, 'final', 0)
+VERSION = (3, 2, 17, 'alpha', 0)
__version__ = get_version(VERSION)
From accdd0576d50b4696378ed1ab651c20affcae08b Mon Sep 17 00:00:00 2001
From: Carlton Gibson
Date: Tue, 4 Oct 2022 10:12:11 +0200
Subject: [PATCH 02/44] [3.2.x] Added CVE-2022-36359 to security archive.
Backport of 93d4c9ea1de24eb391cb2b3561b6703fd46374df from main
---
docs/releases/security.txt | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/docs/releases/security.txt b/docs/releases/security.txt
index f039379e0ed..940f91fa0fb 100644
--- a/docs/releases/security.txt
+++ b/docs/releases/security.txt
@@ -36,6 +36,17 @@ Issues under Django's security process
All security issues have been handled under versions of Django's security
process. These are listed below.
+October 4, 2022 - :cve:`2022-41323`
+-----------------------------------
+
+Potential denial-of-service vulnerability in internationalized URLs. `Full
+description
+`__
+
+* Django 4.1 :commit:`(patch) <9d656ea51d9ea7105c0c0785783ac29d426a7d25>`
+* Django 4.0 :commit:`(patch) <23f0093125ac2e553da6c1b2f9988eb6a3dd2ea1>`
+* Django 3.2 :commit:`(patch) <5b6b257fa7ec37ff27965358800c67e2dd11c924>`
+
August 3, 2022 - :cve:`2022-36359`
----------------------------------
From f6f0699d01f5840437bfd236c76c797943ef8edc Mon Sep 17 00:00:00 2001
From: Nick Pope
Date: Sat, 29 Oct 2022 12:34:22 +0100
Subject: [PATCH 03/44] [3.2.x] Removed obsolete doc reference to
asyncio.iscoroutinefunction.
Backport of 970f61fefb148284fb2af63b5cc844279254111a from main
---
docs/topics/http/middleware.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/topics/http/middleware.txt b/docs/topics/http/middleware.txt
index aa89f79e0cf..d82a3f6ae89 100644
--- a/docs/topics/http/middleware.txt
+++ b/docs/topics/http/middleware.txt
@@ -314,7 +314,7 @@ If your middleware has both ``sync_capable = True`` and
``async_capable = True``, then Django will pass it the request without
converting it. In this case, you can work out if your middleware will receive
async requests by checking if the ``get_response`` object you are passed is a
-coroutine function, using :py:func:`asyncio.iscoroutinefunction`.
+coroutine function, using ``asyncio.iscoroutinefunction``.
The ``django.utils.decorators`` module contains
:func:`~django.utils.decorators.sync_only_middleware`,
From b381ab4906cdff0ebb4e1c6c8e30996f24be6829 Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Thu, 29 Dec 2022 06:07:51 +0100
Subject: [PATCH 04/44] [3.2.x] Disabled auto-created table of contents entries
on Sphinx 5.2+.
Auto-created table of contents entries for all domain objects (e.g.
functions, classes, attributes, etc.) were added in Sphinx 5.2, see
https://github.com/sphinx-doc/sphinx/issues/6316.
An option to control new table of contents entries was added in Sphinx
5.2.3, see https://github.com/sphinx-doc/sphinx/pull/10886.
Backport of 279967ec859a9a5240318cf29a077539b0e3139f from main
---
docs/conf.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/docs/conf.py b/docs/conf.py
index fabe91cc389..42d350052cb 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -73,6 +73,10 @@
# The root toctree document.
root_doc = "contents"
+# Disable auto-created table of contents entries for all domain objects (e.g.
+# functions, classes, attributes, etc.) in Sphinx 5.2+.
+toc_object_entries = False
+
# General substitutions.
project = 'Django'
copyright = 'Django Software Foundation and contributors'
From 238e8898ac0486f60ca3567d2459cda1d51912f3 Mon Sep 17 00:00:00 2001
From: Stephen <101494292+stephenatgithub@users.noreply.github.com>
Date: Wed, 25 Jan 2023 13:09:57 +0800
Subject: [PATCH 05/44] [3.2.x] Corrected passenv value for tox 4.0.6+.
Backport of 34b328814976a2e2f7907361a494202763649f3f from main
---
tox.ini | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tox.ini b/tox.ini
index 6f3c09fddb1..3b74bb5a135 100644
--- a/tox.ini
+++ b/tox.ini
@@ -19,7 +19,7 @@ basepython = python3
[testenv]
usedevelop = true
# OBJC_DISABLE_INITIALIZE_FORK_SAFETY fixes hung tests for MacOS users. (#30806)
-passenv = DJANGO_SETTINGS_MODULE PYTHONPATH HOME DISPLAY OBJC_DISABLE_INITIALIZE_FORK_SAFETY
+passenv = DJANGO_SETTINGS_MODULE,PYTHONPATH,HOME,DISPLAY,OBJC_DISABLE_INITIALIZE_FORK_SAFETY
setenv =
PYTHONDONTWRITEBYTECODE=1
deps =
From 4e31d3ea55315811db00f3f0d5c677e2344c2f5d Mon Sep 17 00:00:00 2001
From: Carlton Gibson
Date: Wed, 25 Jan 2023 11:57:04 +0100
Subject: [PATCH 06/44] [3.2.x] Added stub release notes for 3.2.17.
Backport of 1df963ad2476726d63be132c0cee47e07b8250d7 from main
---
docs/releases/3.2.17.txt | 9 +++++++++
docs/releases/index.txt | 1 +
2 files changed, 10 insertions(+)
create mode 100644 docs/releases/3.2.17.txt
diff --git a/docs/releases/3.2.17.txt b/docs/releases/3.2.17.txt
new file mode 100644
index 00000000000..6d1118e70cc
--- /dev/null
+++ b/docs/releases/3.2.17.txt
@@ -0,0 +1,9 @@
+===========================
+Django 3.2.17 release notes
+===========================
+
+*February 1, 2023*
+
+Django 3.2.17 fixes a security issue in 3.2.16.
+
+...
diff --git a/docs/releases/index.txt b/docs/releases/index.txt
index 74370dd7479..212993d736d 100644
--- a/docs/releases/index.txt
+++ b/docs/releases/index.txt
@@ -25,6 +25,7 @@ versions of the documentation contain the release notes for any later releases.
.. toctree::
:maxdepth: 1
+ 3.2.17
3.2.16
3.2.15
3.2.14
From d21543182d2cb9947650ecc48c068d1bfb7d0311 Mon Sep 17 00:00:00 2001
From: Carlton Gibson
Date: Wed, 25 Jan 2023 12:26:00 +0100
Subject: [PATCH 07/44] [3.2.x] Adjusted release notes for 3.2.17.
Backport of d8e1442ce2c56282785dd806e5c1147975e8c857 from main
---
docs/releases/3.2.17.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/releases/3.2.17.txt b/docs/releases/3.2.17.txt
index 6d1118e70cc..9eba24d72f1 100644
--- a/docs/releases/3.2.17.txt
+++ b/docs/releases/3.2.17.txt
@@ -4,6 +4,6 @@ Django 3.2.17 release notes
*February 1, 2023*
-Django 3.2.17 fixes a security issue in 3.2.16.
+Django 3.2.17 fixes a security issue with severity "moderate" in 3.2.16.
...
From 4c2b26174f044adc4a6461154385720479eaee55 Mon Sep 17 00:00:00 2001
From: Tim Graham
Date: Sun, 1 May 2022 21:44:04 -0400
Subject: [PATCH 08/44] [3.2.x] Removed 'tests' path prefix in a couple tests.
Backport of 694cf458f16b8d340a3195244196980b2dec34fd from main.
---
tests/i18n/tests.py | 2 +-
tests/sessions_tests/tests.py | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/tests/i18n/tests.py b/tests/i18n/tests.py
index 7edceb1c95c..4c31c3bc664 100644
--- a/tests/i18n/tests.py
+++ b/tests/i18n/tests.py
@@ -1948,7 +1948,7 @@ def test_i18n_locale_paths(self):
def test_i18n_app_dirs(self):
mocked_sender = mock.MagicMock()
- with self.settings(INSTALLED_APPS=['tests.i18n.sampleproject']):
+ with self.settings(INSTALLED_APPS=["i18n.sampleproject"]):
watch_for_translation_changes(mocked_sender)
project_dir = Path(__file__).parent / 'sampleproject' / 'locale'
mocked_sender.watch_dir.assert_any_call(project_dir, '**/*.mo')
diff --git a/tests/sessions_tests/tests.py b/tests/sessions_tests/tests.py
index 73d2a13a9f3..ad5dbdb6ddf 100644
--- a/tests/sessions_tests/tests.py
+++ b/tests/sessions_tests/tests.py
@@ -915,9 +915,9 @@ def test_session_save_does_not_resurrect_session_logged_out_in_other_context(sel
class ClearSessionsCommandTests(SimpleTestCase):
def test_clearsessions_unsupported(self):
msg = (
- "Session engine 'tests.sessions_tests.no_clear_expired' doesn't "
+ "Session engine 'sessions_tests.no_clear_expired' doesn't "
"support clearing expired sessions."
)
- with self.settings(SESSION_ENGINE='tests.sessions_tests.no_clear_expired'):
+ with self.settings(SESSION_ENGINE="sessions_tests.no_clear_expired"):
with self.assertRaisesMessage(management.CommandError, msg):
management.call_command('clearsessions')
From 9da46345d83e5d9ecb60512efb2d2e0b2b02b974 Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Thu, 9 Dec 2021 20:24:38 +0100
Subject: [PATCH 09/44] [3.2.x] Fixed
inspectdb.tests.InspectDBTestCase.test_custom_fields() on SQLite 3.37+.
Use FlexibleFieldLookupDict which is case-insensitive mapping because
SQLite 3.37+ returns some data type names upper-cased e.g. TEXT.
Backport of 974e3b8750fe96c16c9c0b115a72ee4a2171df34 from main
---
tests/inspectdb/tests.py | 11 +++++------
1 file changed, 5 insertions(+), 6 deletions(-)
diff --git a/tests/inspectdb/tests.py b/tests/inspectdb/tests.py
index a0c4aff2e5a..8ef757fc18e 100644
--- a/tests/inspectdb/tests.py
+++ b/tests/inspectdb/tests.py
@@ -302,18 +302,17 @@ def test_custom_fields(self):
Introspection of columns with a custom field (#21090)
"""
out = StringIO()
- orig_data_types_reverse = connection.introspection.data_types_reverse
- try:
- connection.introspection.data_types_reverse = {
+ with mock.patch(
+ 'django.db.connection.introspection.data_types_reverse.base_data_types_reverse',
+ {
'text': 'myfields.TextField',
'bigint': 'BigIntegerField',
- }
+ },
+ ):
call_command('inspectdb', 'inspectdb_columntypes', stdout=out)
output = out.getvalue()
self.assertIn("text_field = myfields.TextField()", output)
self.assertIn("big_int_field = models.BigIntegerField()", output)
- finally:
- connection.introspection.data_types_reverse = orig_data_types_reverse
def test_introspection_errors(self):
"""
From c7e0151fdf33e1b11d488b6f67b94fdf3a30614a Mon Sep 17 00:00:00 2001
From: Nick Pope
Date: Wed, 25 Jan 2023 12:21:48 +0100
Subject: [PATCH 10/44] [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 +++++++++++++++++++++++++-
docs/releases/3.2.17.txt | 10 +++++++-
tests/i18n/tests.py | 12 ++++++++++
3 files changed, 52 insertions(+), 2 deletions(-)
diff --git a/django/utils/translation/trans_real.py b/django/utils/translation/trans_real.py
index 8042f6fdc41..b262a5000a4 100644
--- a/django/utils/translation/trans_real.py
+++ b/django/utils/translation/trans_real.py
@@ -30,6 +30,11 @@
# 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 MDN’s 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/docs/releases/3.2.17.txt b/docs/releases/3.2.17.txt
index 9eba24d72f1..fcc097c5ccd 100644
--- a/docs/releases/3.2.17.txt
+++ b/docs/releases/3.2.17.txt
@@ -6,4 +6,12 @@ Django 3.2.17 release notes
Django 3.2.17 fixes a security issue with severity "moderate" in 3.2.16.
-...
+CVE-2023-23969: Potential denial-of-service via ``Accept-Language`` headers
+===========================================================================
+
+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 large header values are sent.
+
+In order to avoid this vulnerability, the ``Accept-Language`` header is now
+parsed up to a maximum length.
diff --git a/tests/i18n/tests.py b/tests/i18n/tests.py
index 4c31c3bc664..41ec63da99e 100644
--- a/tests/i18n/tests.py
+++ b/tests/i18n/tests.py
@@ -1352,6 +1352,14 @@ def test_parse_spec_http_header(self):
('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 @@ def test_parse_spec_http_header(self):
('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):
From aed1bb56d118937d5d6f3ec72f170779dd8c74cd Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Wed, 1 Feb 2023 09:58:36 +0100
Subject: [PATCH 11/44] [3.2.x] Bumped version for 3.2.17 release.
---
django/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/django/__init__.py b/django/__init__.py
index ad16ceec43f..37b8060e425 100644
--- a/django/__init__.py
+++ b/django/__init__.py
@@ -1,6 +1,6 @@
from django.utils.version import get_version
-VERSION = (3, 2, 17, 'alpha', 0)
+VERSION = (3, 2, 17, 'final', 0)
__version__ = get_version(VERSION)
From 9bd8db3940f529aebafb348c7d6786f29a288916 Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Wed, 1 Feb 2023 10:00:34 +0100
Subject: [PATCH 12/44] [3.2.x] Post-release version bump.
---
django/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/django/__init__.py b/django/__init__.py
index 37b8060e425..b3db8f8bcc7 100644
--- a/django/__init__.py
+++ b/django/__init__.py
@@ -1,6 +1,6 @@
from django.utils.version import get_version
-VERSION = (3, 2, 17, 'final', 0)
+VERSION = (3, 2, 18, 'alpha', 0)
__version__ = get_version(VERSION)
From c35a5788f4c17c580976458b0b04210a91133d20 Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Wed, 1 Feb 2023 12:09:03 +0100
Subject: [PATCH 13/44] [3.2.x] Added CVE-2023-23969 to security archive.
Backport of 36e3eef7d5a4c88671d20a561788679d0d9c334c from main
---
docs/releases/security.txt | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/docs/releases/security.txt b/docs/releases/security.txt
index 940f91fa0fb..0023fed03fd 100644
--- a/docs/releases/security.txt
+++ b/docs/releases/security.txt
@@ -36,6 +36,16 @@ Issues under Django's security process
All security issues have been handled under versions of Django's security
process. These are listed below.
+February 1, 2023 - :cve:`2023-23969`
+------------------------------------
+
+Potential denial-of-service via ``Accept-Language`` headers. `Full description
+`__
+
+* Django 4.1 :commit:`(patch) <9d7bd5a56b1ce0576e8e07a8001373576d277942>`
+* Django 4.0 :commit:`(patch) <4452642f193533e288a52c02efb5bbc766a68f95>`
+* Django 3.2 :commit:`(patch) `
+
October 4, 2022 - :cve:`2022-41323`
-----------------------------------
From 932b5bd52d8d7e9255264fdbf425e322efac0b97 Mon Sep 17 00:00:00 2001
From: Carlton Gibson
Date: Thu, 2 Feb 2023 11:54:09 +0100
Subject: [PATCH 14/44] [3.2.x] Added stub release notes for 3.2.18.
Backport of 7e003428f96d616c1f77fed84882a95e63bc3644 from main
---
docs/releases/3.2.18.txt | 9 +++++++++
docs/releases/index.txt | 1 +
2 files changed, 10 insertions(+)
create mode 100644 docs/releases/3.2.18.txt
diff --git a/docs/releases/3.2.18.txt b/docs/releases/3.2.18.txt
new file mode 100644
index 00000000000..431d04c9897
--- /dev/null
+++ b/docs/releases/3.2.18.txt
@@ -0,0 +1,9 @@
+===========================
+Django 3.2.18 release notes
+===========================
+
+*February 14, 2023*
+
+Django 3.2.18 fixes a security issue with severity "moderate" in 3.2.17.
+
+...
diff --git a/docs/releases/index.txt b/docs/releases/index.txt
index 212993d736d..de60213273f 100644
--- a/docs/releases/index.txt
+++ b/docs/releases/index.txt
@@ -25,6 +25,7 @@ versions of the documentation contain the release notes for any later releases.
.. toctree::
:maxdepth: 1
+ 3.2.18
3.2.17
3.2.16
3.2.15
From a665ed5179f5bbd3db95ce67286d0192eff041d8 Mon Sep 17 00:00:00 2001
From: Markus Holtermann
Date: Tue, 13 Dec 2022 10:27:39 +0100
Subject: [PATCH 15/44] [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 cf9fae496e3..4a27887a8f0 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 673d004d575..83161a58cd6 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 3005a5eccb1..2ecc2a0fd69 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 35a54f4ca12..d8a304d4bab 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 195341ec4b6..b6cd7a372f1 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 2f5aa64b9d9..7d34025cd65 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 ` 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 9bfadbc89bd..9173009c94d 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/docs/releases/3.2.18.txt b/docs/releases/3.2.18.txt
index 431d04c9897..46c0feb51e4 100644
--- a/docs/releases/3.2.18.txt
+++ b/docs/releases/3.2.18.txt
@@ -6,4 +6,12 @@ Django 3.2.18 release notes
Django 3.2.18 fixes a security issue with severity "moderate" in 3.2.17.
-...
+CVE-2023-24580: Potential denial-of-service vulnerability in file uploads
+=========================================================================
+
+Passing certain inputs to multipart forms could result in too many open files
+or memory exhaustion, and provided a potential vector for a denial-of-service
+attack.
+
+The number of files parts parsed is now limited via the new
+:setting:`DATA_UPLOAD_MAX_NUMBER_FILES` setting.
diff --git a/tests/handlers/test_exception.py b/tests/handlers/test_exception.py
index 0c1e7639904..7de2edaeea3 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 44897cc9fa9..ded778b4228 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', '']))
From 722e9f8a38f5b34f2423059a75f8a59bb8eb931a Mon Sep 17 00:00:00 2001
From: Carlton Gibson
Date: Tue, 14 Feb 2023 09:04:22 +0100
Subject: [PATCH 16/44] [3.2.x] Bumped version for 3.2.18 release.
---
django/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/django/__init__.py b/django/__init__.py
index b3db8f8bcc7..28f109f9d8e 100644
--- a/django/__init__.py
+++ b/django/__init__.py
@@ -1,6 +1,6 @@
from django.utils.version import get_version
-VERSION = (3, 2, 18, 'alpha', 0)
+VERSION = (3, 2, 18, 'final', 0)
__version__ = get_version(VERSION)
From e34a2283f2b66cbc0239b9443544bacac0576ef1 Mon Sep 17 00:00:00 2001
From: Carlton Gibson
Date: Tue, 14 Feb 2023 09:07:53 +0100
Subject: [PATCH 17/44] [3.2.x] Post-release version bump.
---
django/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/django/__init__.py b/django/__init__.py
index 28f109f9d8e..396186c816e 100644
--- a/django/__init__.py
+++ b/django/__init__.py
@@ -1,6 +1,6 @@
from django.utils.version import get_version
-VERSION = (3, 2, 18, 'final', 0)
+VERSION = (3, 2, 19, 'alpha', 0)
__version__ = get_version(VERSION)
From 963f24cff2820d2b2902da0a9218e93c3933e713 Mon Sep 17 00:00:00 2001
From: Carlton Gibson
Date: Tue, 14 Feb 2023 09:52:30 +0100
Subject: [PATCH 18/44] [3.2.x] Added CVE-2023-24580 to security archive.
Backport of ecafcaf634fcef93f9da8cb12795273dd1c3a576 from main
---
docs/releases/security.txt | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/docs/releases/security.txt b/docs/releases/security.txt
index 0023fed03fd..0a827387094 100644
--- a/docs/releases/security.txt
+++ b/docs/releases/security.txt
@@ -36,6 +36,16 @@ Issues under Django's security process
All security issues have been handled under versions of Django's security
process. These are listed below.
+February 14, 2023 - :cve:`2023-24580`
+-------------------------------------
+
+Potential denial-of-service vulnerability in file uploads. `Full description
+`__
+
+* Django 4.1 :commit:`(patch) <628b33a854a9c68ec8a0c51f382f304a0044ec92>`
+* Django 4.0 :commit:`(patch) <83f1ea83e4553e211c1c5a0dfc197b66d4e50432>`
+* Django 3.2 :commit:`(patch) `
+
February 1, 2023 - :cve:`2023-23969`
------------------------------------
From a37e4d5d6ec5df97d81df0793b390d61212e645e Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Thu, 20 Apr 2023 09:01:59 +0200
Subject: [PATCH 19/44] [3.2.x] Added stub release notes for 3.2.19.
Backport of 18a7f2c711529f8e43c36190a5e2479f13899749 from main
---
docs/releases/3.2.19.txt | 9 +++++++++
docs/releases/index.txt | 1 +
2 files changed, 10 insertions(+)
create mode 100644 docs/releases/3.2.19.txt
diff --git a/docs/releases/3.2.19.txt b/docs/releases/3.2.19.txt
new file mode 100644
index 00000000000..c5817e689c5
--- /dev/null
+++ b/docs/releases/3.2.19.txt
@@ -0,0 +1,9 @@
+===========================
+Django 3.2.19 release notes
+===========================
+
+*May 3, 2023*
+
+Django 3.2.19 fixes a security issue with severity "low" in 3.2.18.
+
+...
diff --git a/docs/releases/index.txt b/docs/releases/index.txt
index de60213273f..0b045b730aa 100644
--- a/docs/releases/index.txt
+++ b/docs/releases/index.txt
@@ -25,6 +25,7 @@ versions of the documentation contain the release notes for any later releases.
.. toctree::
:maxdepth: 1
+ 3.2.19
3.2.18
3.2.17
3.2.16
From 007e46d815063d598e0d3db78bfb371100e5c61c Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Wed, 26 Apr 2023 09:30:14 +0200
Subject: [PATCH 20/44] [3.2.x] Added missing backticks in
docs/releases/1.7.txt.
---
docs/releases/1.7.txt | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/releases/1.7.txt b/docs/releases/1.7.txt
index 6403ae0b517..814ff6db230 100644
--- a/docs/releases/1.7.txt
+++ b/docs/releases/1.7.txt
@@ -1196,8 +1196,8 @@ manager will *not* be reset. This was necessary to resolve an inconsistency in
the way routing information cascaded over joins. See :ticket:`13724` for more
details.
-pytz may be required
---------------------
+``pytz`` may be required
+------------------------
If your project handles datetimes before 1970 or after 2037 and Django raises
a :exc:`ValueError` when encountering them, you will have to install pytz_. You
From eed53d0011622e70b936e203005f0e6f4ac48965 Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Thu, 13 Apr 2023 10:10:56 +0200
Subject: [PATCH 21/44] [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
Co-authored-by: nessita <124304+nessita@users.noreply.github.com>
---
django/forms/widgets.py | 26 ++++++-
docs/releases/3.2.19.txt | 16 ++++-
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 ++++++++++++
6 files changed, 215 insertions(+), 9 deletions(-)
diff --git a/django/forms/widgets.py b/django/forms/widgets.py
index 1b1c1439cb4..8ef82552a19 100644
--- a/django/forms/widgets.py
+++ b/django/forms/widgets.py
@@ -378,16 +378,40 @@ def format_value(self, value):
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/releases/3.2.19.txt b/docs/releases/3.2.19.txt
index c5817e689c5..9f9eb3f45c7 100644
--- a/docs/releases/3.2.19.txt
+++ b/docs/releases/3.2.19.txt
@@ -6,4 +6,18 @@ Django 3.2.19 release notes
Django 3.2.19 fixes a security issue with severity "low" in 3.2.18.
-...
+CVE-2023-31047: Potential bypass of validation when uploading multiple files using one form field
+=================================================================================================
+
+Uploading multiple files using one form field has never been supported by
+:class:`.forms.FileField` or :class:`.forms.ImageField` as only the last
+uploaded file was validated. Unfortunately, :ref:`uploading_multiple_files`
+topic suggested otherwise.
+
+In order to avoid the vulnerability, :class:`~django.forms.ClearableFileInput`
+and :class:`~django.forms.FileInput` form widgets now raise ``ValueError`` when
+the ``multiple`` HTML attribute is set on them. To prevent the exception and
+keep the old behavior, set ``allow_multiple_selected`` to ``True``.
+
+For more details on using the new attribute and handling of multiple files
+through a single field, see :ref:`uploading_multiple_files`.
diff --git a/docs/topics/http/file-uploads.txt b/docs/topics/http/file-uploads.txt
index 7ba6db00bf7..4205b0a1e40 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 2db106e4a0d..b54febd5887 100644
--- a/tests/forms_tests/field_tests/test_filefield.py
+++ b/tests/forms_tests/field_tests/test_filefield.py
@@ -2,7 +2,8 @@
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 @@ def test_disabled_has_changed(self):
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 dee44c42392..6cf1476cb20 100644
--- a/tests/forms_tests/widget_tests/test_clearablefileinput.py
+++ b/tests/forms_tests/widget_tests/test_clearablefileinput.py
@@ -176,3 +176,8 @@ def test_value_omitted_from_data(self):
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 8eec26253af..8068f70b3b6 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 @@ def test_use_required_attribute(self):
# 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)
From fc42edd2e63e89a80e7ca81486291f74359ef8be Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Wed, 3 May 2023 13:59:19 +0200
Subject: [PATCH 22/44] [3.2.x] Bumped version for 3.2.19 release.
---
django/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/django/__init__.py b/django/__init__.py
index 396186c816e..470a9c171c0 100644
--- a/django/__init__.py
+++ b/django/__init__.py
@@ -1,6 +1,6 @@
from django.utils.version import get_version
-VERSION = (3, 2, 19, 'alpha', 0)
+VERSION = (3, 2, 19, 'final', 0)
__version__ = get_version(VERSION)
From 15f90ebff35c605293dcbe9541da90f50288aa25 Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Wed, 3 May 2023 14:00:58 +0200
Subject: [PATCH 23/44] [3.2.x] Post-release version bump.
---
django/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/django/__init__.py b/django/__init__.py
index 470a9c171c0..f0080fe833e 100644
--- a/django/__init__.py
+++ b/django/__init__.py
@@ -1,6 +1,6 @@
from django.utils.version import get_version
-VERSION = (3, 2, 19, 'final', 0)
+VERSION = (3, 2, 20, 'alpha', 0)
__version__ = get_version(VERSION)
From 47ef12e69cfdeeba3388881d2a7234ab3844115e Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Wed, 3 May 2023 15:20:31 +0200
Subject: [PATCH 24/44] [3.2.x] Added CVE-2023-31047 to security archive.
Backport of 49830025c992fbc8d8f213e7c16dba1391c6adf2 from main
---
docs/releases/security.txt | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/docs/releases/security.txt b/docs/releases/security.txt
index 0a827387094..c90d7323978 100644
--- a/docs/releases/security.txt
+++ b/docs/releases/security.txt
@@ -36,6 +36,17 @@ Issues under Django's security process
All security issues have been handled under versions of Django's security
process. These are listed below.
+May 3, 2023 - :cve:`2023-31047`
+-------------------------------
+
+Potential bypass of validation when uploading multiple files using one form
+field. `Full description
+`__
+
+* Django 4.2 :commit:`(patch) <21b1b1fc03e5f9e9f8c977ee6e35618dd3b353dd>`
+* Django 4.1 :commit:`(patch) `
+* Django 3.2 :commit:`(patch) `
+
February 14, 2023 - :cve:`2023-24580`
-------------------------------------
From e1bbbbe6acb69e755554088bc573cc1835673209 Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Thu, 4 May 2023 08:09:02 +0200
Subject: [PATCH 25/44] [3.2.x] Fixed
MultipleFileFieldTest.test_file_multiple_validation() test if Pillow isn't
installed.
Follow up to fb4c55d9ec4bb812a7fb91fa20510d91645e411b.
Backport of fcfbf08abe3e6dc54894df6988024f055abc6c40 from main
---
tests/forms_tests/field_tests/test_filefield.py | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/tests/forms_tests/field_tests/test_filefield.py b/tests/forms_tests/field_tests/test_filefield.py
index b54febd5887..b04a70a7596 100644
--- a/tests/forms_tests/field_tests/test_filefield.py
+++ b/tests/forms_tests/field_tests/test_filefield.py
@@ -1,4 +1,5 @@
import pickle
+import unittest
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
@@ -6,6 +7,13 @@
from django.forms import FileField, FileInput
from django.test import SimpleTestCase
+try:
+ from PIL import Image # NOQA
+except ImportError:
+ HAS_PILLOW = False
+else:
+ HAS_PILLOW = True
+
class FileFieldTest(SimpleTestCase):
@@ -125,6 +133,7 @@ def test_file_multiple_empty(self):
with self.assertRaisesMessage(ValidationError, msg):
f.clean(files[::-1])
+ @unittest.skipUnless(HAS_PILLOW, "Pillow not installed")
def test_file_multiple_validation(self):
f = MultipleFileField(validators=[validate_image_file_extension])
From 07cc014cb30bc3c343a25c81aad6820bbc72c0d9 Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Wed, 21 Jun 2023 08:59:10 +0200
Subject: [PATCH 26/44] [3.2.x] Added stub release notes for 3.2.20.
Backport of 2360ba22742c3ee8729697bfe2d508110465af56 from main
---
docs/releases/3.2.20.txt | 9 +++++++++
docs/releases/index.txt | 1 +
2 files changed, 10 insertions(+)
create mode 100644 docs/releases/3.2.20.txt
diff --git a/docs/releases/3.2.20.txt b/docs/releases/3.2.20.txt
new file mode 100644
index 00000000000..e4ef9143940
--- /dev/null
+++ b/docs/releases/3.2.20.txt
@@ -0,0 +1,9 @@
+===========================
+Django 3.2.20 release notes
+===========================
+
+*July 3, 2023*
+
+Django 3.2.20 fixes a security issue with severity "moderate" in 3.2.19.
+
+...
diff --git a/docs/releases/index.txt b/docs/releases/index.txt
index 0b045b730aa..1da0a752b32 100644
--- a/docs/releases/index.txt
+++ b/docs/releases/index.txt
@@ -25,6 +25,7 @@ versions of the documentation contain the release notes for any later releases.
.. toctree::
:maxdepth: 1
+ 3.2.20
3.2.19
3.2.18
3.2.17
From 454f2fb93437f98917283336201b4048293f7582 Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Wed, 14 Jun 2023 12:23:06 +0200
Subject: [PATCH 27/44] [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 731ccf2d469..b9b58dfa617 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 0214d60c1cf..8adb09e3829 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 9438214a28c..5b485f21538 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 50761e5a425..b22762b17b9 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/docs/releases/3.2.20.txt b/docs/releases/3.2.20.txt
index e4ef9143940..c8f60a70e2d 100644
--- a/docs/releases/3.2.20.txt
+++ b/docs/releases/3.2.20.txt
@@ -6,4 +6,9 @@ Django 3.2.20 release notes
Django 3.2.20 fixes a security issue with severity "moderate" in 3.2.19.
-...
+CVE-2023-36053: Potential regular expression denial of service vulnerability in ``EmailValidator``/``URLValidator``
+===================================================================================================================
+
+``EmailValidator`` and ``URLValidator`` were subject to potential regular
+expression denial of service attack via a very large number of domain name
+labels of emails and URLs.
diff --git a/tests/forms_tests/field_tests/test_emailfield.py b/tests/forms_tests/field_tests/test_emailfield.py
index 8b85e4dcc14..19d315205d7 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, '')
+ self.assertEqual(f.max_length, 320)
+ self.assertWidgetRendersTo(
+ f, ''
+ )
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 26f8ecafea4..82a32af403a 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']), '')
+ self.assertHTMLEqual(
+ str(f["email"]),
+ '',
+ )
self.assertHTMLEqual(str(f['get_spam']), '')
f = SignupForm({'email': 'test@example.com', 'get_spam': True}, auto_id=False)
- self.assertHTMLEqual(str(f['email']), '')
+ self.assertHTMLEqual(
+ str(f["email"]),
+ '",
+ )
self.assertHTMLEqual(
str(f['get_spam']),
'',
@@ -2824,7 +2831,7 @@ class Person(Form):
-
+
This field is required.
"""
)
@@ -2840,7 +2847,7 @@ class Person(Form):
-
+
This field is required.
"""
@@ -2859,7 +2866,7 @@ class Person(Form):
-
+
This field is required.
"""
@@ -3489,7 +3496,7 @@ class CommentForm(Form):
f = CommentForm(data, auto_id=False, error_class=DivErrorList)
self.assertHTMLEqual(f.as_p(), """
Name:
Enter a valid email address.
-
Email:
+
Email:
This field is required.
Comment:
""")
diff --git a/tests/validators/tests.py b/tests/validators/tests.py
index e39d0e3a1ce..1065727a974 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),
From 19bc11f636ca2b5b80c3d9ad5b489e43abad52bb Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Mon, 3 Jul 2023 08:33:38 +0200
Subject: [PATCH 28/44] [3.2.x] Bumped version for 3.2.20 release.
---
django/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/django/__init__.py b/django/__init__.py
index f0080fe833e..241cd99c8b3 100644
--- a/django/__init__.py
+++ b/django/__init__.py
@@ -1,6 +1,6 @@
from django.utils.version import get_version
-VERSION = (3, 2, 20, 'alpha', 0)
+VERSION = (3, 2, 20, 'final', 0)
__version__ = get_version(VERSION)
From 4012a87a58292f34e72181d9f12c94317ac6e911 Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Mon, 3 Jul 2023 08:36:12 +0200
Subject: [PATCH 29/44] [3.2.x] Post-release version bump.
---
django/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/django/__init__.py b/django/__init__.py
index 241cd99c8b3..c2c0da8b793 100644
--- a/django/__init__.py
+++ b/django/__init__.py
@@ -1,6 +1,6 @@
from django.utils.version import get_version
-VERSION = (3, 2, 20, 'final', 0)
+VERSION = (3, 2, 21, 'alpha', 0)
__version__ = get_version(VERSION)
From 848fe70f3ee6dc151831251076dc0a4a9db5a0ec Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Mon, 3 Jul 2023 10:10:48 +0200
Subject: [PATCH 30/44] [3.2.x] Added CVE-2023-36053 to security archive.
Backport of 1d6fbf16f24200a556beb6dd197439944deb6837 from main
---
docs/releases/security.txt | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/docs/releases/security.txt b/docs/releases/security.txt
index c90d7323978..48586c8a6e9 100644
--- a/docs/releases/security.txt
+++ b/docs/releases/security.txt
@@ -36,6 +36,17 @@ Issues under Django's security process
All security issues have been handled under versions of Django's security
process. These are listed below.
+July 3, 2023 - :cve:`2023-36053`
+--------------------------------
+
+Potential regular expression denial of service vulnerability in
+``EmailValidator``/``URLValidator``. `Full description
+`__
+
+* Django 4.2 :commit:`(patch) `
+* Django 4.1 :commit:`(patch) `
+* Django 3.2 :commit:`(patch) <454f2fb93437f98917283336201b4048293f7582>`
+
May 3, 2023 - :cve:`2023-31047`
-------------------------------
From 75418f8c0e5e8fcfb557bf8e69fc1ccfd64b697d Mon Sep 17 00:00:00 2001
From: David Smith
Date: Tue, 1 Aug 2023 21:22:53 +0100
Subject: [PATCH 31/44] [3.2.x] Fixed #34756 -- Fixed docs HTML build on Sphinx
7.1+.
Backport of b3e0170ab546a96930ce3114b0a1a560953c0ff4 from main
---
docs/_ext/djangodocs.py | 17 +++++++++++++++--
1 file changed, 15 insertions(+), 2 deletions(-)
diff --git a/docs/_ext/djangodocs.py b/docs/_ext/djangodocs.py
index 5208a532554..6eadc23144d 100644
--- a/docs/_ext/djangodocs.py
+++ b/docs/_ext/djangodocs.py
@@ -130,10 +130,23 @@ def depart_table(self, node):
def visit_desc_parameterlist(self, node):
self.body.append('(') # by default sphinx puts around the "("
- self.first_param = 1
self.optional_param_level = 0
self.param_separator = node.child_text_separator
- self.required_params_left = sum(isinstance(c, addnodes.desc_parameter) for c in node.children)
+ # Counts 'parameter groups' being either a required parameter, or a set
+ # of contiguous optional ones.
+ required_params = [
+ isinstance(c, addnodes.desc_parameter) for c in node.children
+ ]
+ # How many required parameters are left.
+ self.required_params_left = sum(required_params)
+ if sphinx_version < (7, 1):
+ self.first_param = 1
+ else:
+ self.is_first_param = True
+ self.params_left_at_level = 0
+ self.param_group_index = 0
+ self.list_is_required_param = required_params
+ self.multi_line_parameter_list = False
def depart_desc_parameterlist(self, node):
self.body.append(')')
From 73350a63698199c9f1269647722ea96c7f9a8aa0 Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Thu, 24 Aug 2023 07:56:05 +0200
Subject: [PATCH 32/44] [3.2.x] Added stub release notes for 3.2.21.
Backport of 24f1a38b37c0af3a5ce0dd7b5392fe4e75d7e1dc from main.
---
docs/releases/3.2.21.txt | 9 +++++++++
docs/releases/index.txt | 1 +
2 files changed, 10 insertions(+)
create mode 100644 docs/releases/3.2.21.txt
diff --git a/docs/releases/3.2.21.txt b/docs/releases/3.2.21.txt
new file mode 100644
index 00000000000..79efc679d17
--- /dev/null
+++ b/docs/releases/3.2.21.txt
@@ -0,0 +1,9 @@
+===========================
+Django 3.2.21 release notes
+===========================
+
+*September 4, 2023*
+
+Django 3.2.21 fixes a security issue with severity "moderate" in 3.2.20.
+
+...
diff --git a/docs/releases/index.txt b/docs/releases/index.txt
index 1da0a752b32..e0ae1b15803 100644
--- a/docs/releases/index.txt
+++ b/docs/releases/index.txt
@@ -25,6 +25,7 @@ versions of the documentation contain the release notes for any later releases.
.. toctree::
:maxdepth: 1
+ 3.2.21
3.2.20
3.2.19
3.2.18
From 6f030b1149bd8fa4ba90452e77cb3edc095ce54e Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Tue, 22 Aug 2023 08:53:03 +0200
Subject: [PATCH 33/44] [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.
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 e1ebacef470..c5c4463b1c2 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/docs/releases/3.2.21.txt b/docs/releases/3.2.21.txt
index 79efc679d17..062ac66682a 100644
--- a/docs/releases/3.2.21.txt
+++ b/docs/releases/3.2.21.txt
@@ -6,4 +6,9 @@ Django 3.2.21 release notes
Django 3.2.21 fixes a security issue with severity "moderate" in 3.2.20.
-...
+CVE-2023-41164: Potential denial of service vulnerability in ``django.utils.encoding.uri_to_iri()``
+===================================================================================================
+
+``django.utils.encoding.uri_to_iri()`` was subject to potential denial of
+service attack via certain inputs with a very large number of Unicode
+characters.
diff --git a/tests/utils_tests/test_encoding.py b/tests/utils_tests/test_encoding.py
index 36f2d8665f3..42779050cb3 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):
From fd0ccd7fb3ec8aee9a88ab0576059c7393397c8d Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Mon, 4 Sep 2023 12:23:57 +0200
Subject: [PATCH 34/44] [3.2.x] Bumped version for 3.2.21 release.
---
django/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/django/__init__.py b/django/__init__.py
index c2c0da8b793..48e561f860f 100644
--- a/django/__init__.py
+++ b/django/__init__.py
@@ -1,6 +1,6 @@
from django.utils.version import get_version
-VERSION = (3, 2, 21, 'alpha', 0)
+VERSION = (3, 2, 21, 'final', 0)
__version__ = get_version(VERSION)
From 4b439dcd05ba1fbd0202382affa4df71111831da Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Mon, 4 Sep 2023 12:25:28 +0200
Subject: [PATCH 35/44] [3.2.x] Post-release version bump.
---
django/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/django/__init__.py b/django/__init__.py
index 48e561f860f..919d1ac989a 100644
--- a/django/__init__.py
+++ b/django/__init__.py
@@ -1,6 +1,6 @@
from django.utils.version import get_version
-VERSION = (3, 2, 21, 'final', 0)
+VERSION = (3, 2, 22, 'alpha', 0)
__version__ = get_version(VERSION)
From 9e814c3a5e399cb5fa3ea2f0f3b9536dc7302fea Mon Sep 17 00:00:00 2001
From: Mariusz Felisiak
Date: Mon, 4 Sep 2023 13:09:48 +0200
Subject: [PATCH 36/44] [3.2.x] Added CVE-2023-41164 to security archive.
Backport of 8a98768868a104ea3ce10d8182590bdd095d9ccb from main
---
docs/releases/security.txt | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/docs/releases/security.txt b/docs/releases/security.txt
index 48586c8a6e9..34394c50b0b 100644
--- a/docs/releases/security.txt
+++ b/docs/releases/security.txt
@@ -36,6 +36,17 @@ Issues under Django's security process
All security issues have been handled under versions of Django's security
process. These are listed below.
+September 4, 2023 - :cve:`2023-41164`
+-------------------------------------
+
+Potential denial of service vulnerability in
+``django.utils.encoding.uri_to_iri()``. `Full description
+`__
+
+* Django 4.2 :commit:`(patch) <9c51b4dcfa0cefcb48231f4d71cafa80821f87b9>`
+* Django 4.1 :commit:`(patch) `
+* Django 3.2 :commit:`(patch) <6f030b1149bd8fa4ba90452e77cb3edc095ce54e>`
+
July 3, 2023 - :cve:`2023-36053`
--------------------------------
From 6caf7b313d279d0002bc27b81a92c0bf7cc86e41 Mon Sep 17 00:00:00 2001
From: Natalia <124304+nessita@users.noreply.github.com>
Date: Wed, 27 Sep 2023 14:18:40 -0300
Subject: [PATCH 37/44] [3.2.x] Added stub release notes for 3.2.22.
Backport of 24f1a38b37c0af3a5ce0dd7b5392fe4e75d7e1dc from main.
---
docs/releases/3.2.22.txt | 9 +++++++++
docs/releases/index.txt | 1 +
2 files changed, 10 insertions(+)
create mode 100644 docs/releases/3.2.22.txt
diff --git a/docs/releases/3.2.22.txt b/docs/releases/3.2.22.txt
new file mode 100644
index 00000000000..6e1815de11d
--- /dev/null
+++ b/docs/releases/3.2.22.txt
@@ -0,0 +1,9 @@
+===========================
+Django 3.2.22 release notes
+===========================
+
+*October 4, 2023*
+
+Django 3.2.22 fixes a security issue with severity "moderate" in 3.2.21.
+
+...
diff --git a/docs/releases/index.txt b/docs/releases/index.txt
index e0ae1b15803..b35f225b53f 100644
--- a/docs/releases/index.txt
+++ b/docs/releases/index.txt
@@ -25,6 +25,7 @@ versions of the documentation contain the release notes for any later releases.
.. toctree::
:maxdepth: 1
+ 3.2.22
3.2.21
3.2.20
3.2.19
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 38/44] [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.
---
django/utils/text.py | 18 ++++++++++++++++-
docs/ref/templates/builtins.txt | 20 +++++++++++++++++++
docs/releases/3.2.22.txt | 18 ++++++++++++++++-
tests/utils_tests/test_text.py | 35 ++++++++++++++++++++++++---------
4 files changed, 80 insertions(+), 11 deletions(-)
diff --git a/django/utils/text.py b/django/utils/text.py
index baa44f279e8..83e258fa81c 100644
--- a/django/utils/text.py
+++ b/django/utils/text.py
@@ -60,7 +60,14 @@ def _generator():
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 @@ def _truncate_html(self, length, truncate, text, truncate_len, words):
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 @@ def _truncate_html(self, length, truncate, text, truncate_len, words):
# 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 22509a2a7e0..a6fd97175b7 100644
--- a/docs/ref/templates/builtins.txt
+++ b/docs/ref/templates/builtins.txt
@@ -2348,6 +2348,16 @@ If ``value`` is ``"
Joel is a slug
"``, 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 ``"
Joel is a slug
"``, 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/docs/releases/3.2.22.txt b/docs/releases/3.2.22.txt
index 6e1815de11d..cfedc41de8b 100644
--- a/docs/releases/3.2.22.txt
+++ b/docs/releases/3.2.22.txt
@@ -6,4 +6,20 @@ Django 3.2.22 release notes
Django 3.2.22 fixes a security issue with severity "moderate" in 3.2.21.
-...
+CVE-2023-43665: Denial-of-service possibility in ``django.utils.text.Truncator``
+================================================================================
+
+Following the fix for :cve:`2019-14232`, the regular expressions used in the
+implementation of ``django.utils.text.Truncator``'s ``chars()`` and ``words()``
+methods (with ``html=True``) were revised and improved. However, these regular
+expressions still exhibited linear backtracking complexity, so when given a
+very long, potentially malformed HTML input, the evaluation would still be
+slow, leading to a potential denial of service vulnerability.
+
+The ``chars()`` and ``words()`` methods are used to implement the
+:tfilter:`truncatechars_html` and :tfilter:`truncatewords_html` template
+filters, which were thus also vulnerable.
+
+The input processed by ``Truncator``, when operating in HTML mode, has been
+limited to the first five million characters in order to avoid potential
+performance and memory issues.
diff --git a/tests/utils_tests/test_text.py b/tests/utils_tests/test_text.py
index d2a94fcdabe..0a6f0bc3f26 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 @@ def test_truncate_chars(self):
# 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 = "