Version in base suite: 2.2.28-1~deb11u1 Base version: python-django_2.2.28-1~deb11u1 Target version: python-django_2.2.28-1~deb11u2 Base file: /srv/ftp-master.debian.org/ftp/pool/main/p/python-django/python-django_2.2.28-1~deb11u1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/p/python-django/python-django_2.2.28-1~deb11u2.dsc changelog | 41 +++ gitlab-ci.yml | 11 + patches/0007-fix-url-validator.patch | 51 ++++ patches/CVE-2023-23969.patch | 90 ++++++++ patches/CVE-2023-24580.patch | 381 +++++++++++++++++++++++++++++++++++ patches/CVE-2023-31047.patch | 256 +++++++++++++++++++++++ patches/CVE-2023-36053.patch | 202 ++++++++++++++++++ patches/series | 5 8 files changed, 1037 insertions(+) diff -Nru python-django-2.2.28/debian/changelog python-django-2.2.28/debian/changelog --- python-django-2.2.28/debian/changelog 2022-10-14 17:02:41.000000000 +0000 +++ python-django-2.2.28/debian/changelog 2023-07-28 13:19:58.000000000 +0000 @@ -1,3 +1,44 @@ +python-django (2:2.2.28-1~deb11u2) bullseye-security; urgency=high + + * 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. (Closes: #1030251) + + * 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. (Closes: #1040225) + + * CVE-2023-31047: Prevent a potential bypass of validation when uploading + multiple files using one form field. + + Uploading multiple files using one form field has never been supported by + forms.FileField or forms.ImageField as only the last uploaded file was + validated. Unfortunately, Uploading multiple files topic suggested + otherwise. In order to avoid the vulnerability, the ClearableFileInput and + 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 the allow_multiple_selected attribute to True. + (Closes: #1035467) + + * 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 DATA_UPLOAD_MAX_NUMBER_FILES setting. (Closes: #1031290) + + * Add/apply the URLValidator patch from sid. + + -- Chris Lamb Fri, 28 Jul 2023 14:19:58 +0100 + python-django (2:2.2.28-1~deb11u1) bullseye-security; urgency=medium * New upstream security release: diff -Nru python-django-2.2.28/debian/gitlab-ci.yml python-django-2.2.28/debian/gitlab-ci.yml --- python-django-2.2.28/debian/gitlab-ci.yml 1970-01-01 00:00:00.000000000 +0000 +++ python-django-2.2.28/debian/gitlab-ci.yml 2023-07-28 13:19:58.000000000 +0000 @@ -0,0 +1,11 @@ +include: + - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/recipes/debian.yml + +variables: + RELEASE: bullseye + +lintian: + allow_failure: true + +reprotest: + allow_failure: true diff -Nru python-django-2.2.28/debian/patches/0007-fix-url-validator.patch python-django-2.2.28/debian/patches/0007-fix-url-validator.patch --- python-django-2.2.28/debian/patches/0007-fix-url-validator.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-2.2.28/debian/patches/0007-fix-url-validator.patch 2023-07-28 13:19:58.000000000 +0000 @@ -0,0 +1,51 @@ +From: Pedro Schlickmann Mendes +Date: Thu, 20 Jul 2023 12:59:59 +0100 +Subject: Fixed URLValidator crash in some edge cases + +Origin: upstream, https://github.com/django/django/commit/e8b4feddc34ffe5759ec21da8fa027e86e653f1c +Bug: https://code.djangoproject.com/ticket/33367 +Last-Update: 2021-12-15 +--- + django/core/validators.py | 13 +++++++------ + 1 file changed, 7 insertions(+), 6 deletions(-) + +--- python-django.orig/django/core/validators.py ++++ python-django/django/core/validators.py +@@ -118,14 +118,15 @@ class URLValidator(RegexValidator): + + # Then check full URL + try: ++ splitted_url = urlsplit(value) ++ except ValueError: ++ raise ValidationError(self.message, code=self.code, params={'value': value}) ++ try: + super().__call__(value) + except ValidationError as e: + # Trivial case failed. Try for possible IDN domain + if value: +- try: +- scheme, netloc, path, query, fragment = urlsplit(value) +- except ValueError: # for example, "Invalid IPv6 URL" +- raise ValidationError(self.message, code=self.code) ++ scheme, netloc, path, query, fragment = splitted_url + try: + netloc = netloc.encode('idna').decode('ascii') # IDN -> ACE + except UnicodeError: # invalid domain part +@@ -136,7 +137,7 @@ class URLValidator(RegexValidator): + raise + else: + # Now verify IPv6 in the netloc part +- host_match = re.search(r'^\[(.+)\](?::\d{2,5})?$', urlsplit(value).netloc) ++ host_match = re.search(r'^\[(.+)\](?::\d{2,5})?$', splitted_url.netloc) + if host_match: + potential_ip = host_match.groups()[0] + try: +@@ -148,7 +149,7 @@ class URLValidator(RegexValidator): + # section 3.1. It's defined to be 255 bytes or less, but this includes + # one byte for the length of the name and one byte for the trailing dot + # that's used to indicate absolute names in DNS. +- if len(urlsplit(value).netloc) > 253: ++ if splitted_url.hostname is None or len(splitted_url.hostname) > 253: + raise ValidationError(self.message, code=self.code) + + diff -Nru python-django-2.2.28/debian/patches/CVE-2023-23969.patch python-django-2.2.28/debian/patches/CVE-2023-23969.patch --- python-django-2.2.28/debian/patches/CVE-2023-23969.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-2.2.28/debian/patches/CVE-2023-23969.patch 2023-07-28 13:19:58.000000000 +0000 @@ -0,0 +1,90 @@ +From: Nick Pope +Date: Wed, 25 Jan 2023 12:21:48 +0100 +Subject: [PATCH] [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 | 8 ++++++++ + 2 files changed, 39 insertions(+), 1 deletion(-) + +diff --git a/django/utils/translation/trans_real.py b/django/utils/translation/trans_real.py +index 486b2b26b608..f080e51b8310 100644 +--- a/django/utils/translation/trans_real.py ++++ b/django/utils/translation/trans_real.py +@@ -29,6 +29,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 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 = re.compile(r''' +@@ -560,7 +565,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. +@@ -582,3 +587,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 7381cb9c3154..7f70b15504bf 100644 +--- a/tests/i18n/tests.py ++++ b/tests/i18n/tests.py +@@ -1282,6 +1282,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', []), diff -Nru python-django-2.2.28/debian/patches/CVE-2023-24580.patch python-django-2.2.28/debian/patches/CVE-2023-24580.patch --- python-django-2.2.28/debian/patches/CVE-2023-24580.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-2.2.28/debian/patches/CVE-2023-24580.patch 2023-07-28 13:19:58.000000000 +0000 @@ -0,0 +1,381 @@ +From: Markus Holtermann +Date: Tue, 13 Dec 2022 10:27:39 +0100 +Subject: [PATCH] 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 | 3 +- + django/http/multipartparser.py | 62 ++++++++++++++++++++++++----- + django/http/request.py | 6 ++- + docs/ref/settings.txt | 23 +++++++++++ + tests/handlers/test_exception.py | 31 ++++++++++++++- + tests/requests/test_data_upload_settings.py | 53 +++++++++++++++++++++++- + 8 files changed, 175 insertions(+), 16 deletions(-) + +diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py +index f3abfada25f2..7f4cd545c21f 100644 +--- a/django/conf/global_settings.py ++++ b/django/conf/global_settings.py +@@ -299,6 +299,10 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = 2621440 # i.e. 2.5 MB + # 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 0e85397b9c74..eb5562924d20 100644 +--- a/django/core/exceptions.py ++++ b/django/core/exceptions.py +@@ -55,6 +55,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 66443ce56015..4eea18baaec4 100644 +--- a/django/core/handlers/exception.py ++++ b/django/core/handlers/exception.py +@@ -7,6 +7,7 @@ from django.core import signals + from django.core.exceptions import ( + PermissionDenied, RequestDataTooBig, SuspiciousOperation, + TooManyFieldsSent, ++ TooManyFilesSent, + ) + from django.http import Http404 + from django.http.multipartparser import MultiPartParserError +@@ -64,7 +65,7 @@ def response_for_exception(request, exc): + ) + + 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 259128acefce..a879b4893ac3 100644 +--- a/django/http/multipartparser.py ++++ b/django/http/multipartparser.py +@@ -13,6 +13,7 @@ from urllib.parse import unquote + from django.conf import settings + from django.core.exceptions import ( + RequestDataTooBig, SuspiciousMultipartForm, TooManyFieldsSent, ++ TooManyFilesSent, + ) + from django.core.files.uploadhandler import ( + SkipFile, StopFutureHandlers, StopUpload, +@@ -37,6 +38,7 @@ class InputStreamExhausted(Exception): + RAW = "raw" + FILE = "file" + FIELD = "field" ++FIELD_TYPES = frozenset([FIELD, RAW]) + + + class MultiPartParser: +@@ -98,6 +100,22 @@ class MultiPartParser: + 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. +@@ -143,6 +161,8 @@ class MultiPartParser: + 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 + +@@ -155,6 +175,20 @@ class MultiPartParser: + self.handle_file_complete(old_field_name, counters) + old_field_name = None + ++ 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() +@@ -167,15 +201,6 @@ class MultiPartParser: + field_name = force_text(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 +@@ -201,6 +226,16 @@ class MultiPartParser: + + self._post.appendlist(field_name, force_text(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: +@@ -268,8 +303,13 @@ class MultiPartParser: + # 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 c93b4d478218..cff375a29704 100644 +--- a/django/http/request.py ++++ b/django/http/request.py +@@ -11,7 +11,9 @@ from django.core.exceptions import ( + DisallowedHost, ImproperlyConfigured, RequestDataTooBig, + ) + 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, + ) +@@ -313,7 +315,7 @@ class HttpRequest: + 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/settings.txt b/docs/ref/settings.txt +index daca1bea5690..8f562c97ee21 100644 +--- a/docs/ref/settings.txt ++++ b/docs/ref/settings.txt +@@ -1012,6 +1012,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`` +@@ -3449,6 +3471,7 @@ HTTP + ---- + * :setting:`DATA_UPLOAD_MAX_MEMORY_SIZE` + * :setting:`DATA_UPLOAD_MAX_NUMBER_FIELDS` ++* :setting:`DATA_UPLOAD_MAX_NUMBER_FILES` + * :setting:`DEFAULT_CHARSET` + * :setting:`DEFAULT_CONTENT_TYPE` + * :setting:`DISALLOWED_USER_AGENTS` +diff --git a/tests/handlers/test_exception.py b/tests/handlers/test_exception.py +index 0c1e76399045..cf1e51750afd 100644 +--- a/tests/handlers/test_exception.py ++++ b/tests/handlers/test_exception.py +@@ -1,6 +1,11 @@ + 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 +30,27 @@ class ExceptionHandlerTests(SimpleTestCase): + 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..fae22cac3fa4 100644 +--- a/tests/requests/test_data_upload_settings.py ++++ b/tests/requests/test_data_upload_settings.py +@@ -1,12 +1,17 @@ + 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_MUCH_DATA_MSG = 'Request body exceeded settings.DATA_UPLOAD_MAX_MEMORY_SIZE.' ++TOO_MANY_FILES_MSG = 'The number of files exceeded settings.DATA_UPLOAD_MAX_NUMBER_FILES.' + + + class DataUploadMaxMemorySizeFormPostTests(SimpleTestCase): +@@ -166,6 +171,52 @@ class DataUploadMaxNumberOfFieldsMultipartPost(SimpleTestCase): + 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', ''])) diff -Nru python-django-2.2.28/debian/patches/CVE-2023-31047.patch python-django-2.2.28/debian/patches/CVE-2023-31047.patch --- python-django-2.2.28/debian/patches/CVE-2023-31047.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-2.2.28/debian/patches/CVE-2023-31047.patch 2023-07-28 13:19:58.000000000 +0000 @@ -0,0 +1,256 @@ +From: Mariusz Felisiak +Date: Thu, 13 Apr 2023 10:10:56 +0200 +Subject: [PATCH] [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/topics/http/file-uploads.txt | 24 +++++++- + tests/forms_tests/field_tests/test_filefield.py | 68 +++++++++++++++++++++- + .../widget_tests/test_clearablefileinput.py | 5 ++ + tests/forms_tests/widget_tests/test_fileinput.py | 43 ++++++++++++++ + 5 files changed, 161 insertions(+), 5 deletions(-) + +diff --git a/django/forms/widgets.py b/django/forms/widgets.py +index e37036c0c991..a39c76fa9d56 100644 +--- a/django/forms/widgets.py ++++ b/django/forms/widgets.py +@@ -373,16 +373,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__.__name__ ++ ) ++ if self.allow_multiple_selected: ++ if attrs is None: ++ attrs = {"multiple": True} ++ else: ++ attrs.setdefault("multiple", True) ++ super(FileInput, self).__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 21a6f06853ef..1c660eb0fd4c 100644 +--- a/docs/topics/http/file-uploads.txt ++++ b/docs/topics/http/file-uploads.txt +@@ -159,14 +159,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(FileFieldView, self).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:: 1.11.29-1+deb10u8 ++ ++ 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 33574446f4cb..8227da60e6f1 100644 +--- a/tests/forms_tests/field_tests/test_filefield.py ++++ b/tests/forms_tests/field_tests/test_filefield.py +@@ -1,7 +1,8 @@ + import pickle + + from django.core.files.uploadedfile import SimpleUploadedFile +-from django.forms import FileField, ValidationError ++from django.core.validators import validate_image_file_extension ++from django.forms import FileField, FileInput, ValidationError + from django.test import SimpleTestCase + + +@@ -82,3 +83,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(MultipleFileField, self).__init__(*args, **kwargs) ++ ++ def clean(self, data, initial=None): ++ single_file_clean = super(MultipleFileField, self).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 2ba376db8a9f..8d9e38af3ccb 100644 +--- a/tests/forms_tests/widget_tests/test_clearablefileinput.py ++++ b/tests/forms_tests/widget_tests/test_clearablefileinput.py +@@ -161,3 +161,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 bbd7c7fe527c..eb6051fdb0fc 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 + +@@ -18,3 +20,44 @@ class FileInputTest(WidgetTest): + def test_value_omitted_from_data(self): + self.assertIs(self.widget.value_omitted_from_data({}, {}, 'field'), True) + self.assertIs(self.widget.value_omitted_from_data({}, {'field': 'value'}, 'field'), 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: ++ widget = MultipleFileInput(attrs=attrs) ++ self.assertIs(widget.attrs["multiple"], expected) diff -Nru python-django-2.2.28/debian/patches/CVE-2023-36053.patch python-django-2.2.28/debian/patches/CVE-2023-36053.patch --- python-django-2.2.28/debian/patches/CVE-2023-36053.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-2.2.28/debian/patches/CVE-2023-36053.patch 2023-07-28 13:19:58.000000000 +0000 @@ -0,0 +1,202 @@ +From: Mariusz Felisiak +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/validators.txt | 17 +++++++++++++++++ + tests/forms_tests/field_tests/test_emailfield.py | 5 ++++- + tests/forms_tests/tests/test_forms.py | 19 +++++++++++++------ + tests/validators/tests.py | 11 +++++++++++ + 6 files changed, 54 insertions(+), 8 deletions(-) + +diff --git a/django/core/validators.py b/django/core/validators.py +index a80d736f018f..4250139717b9 100644 +--- a/django/core/validators.py ++++ b/django/core/validators.py +@@ -102,6 +102,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) +@@ -111,6 +112,8 @@ class URLValidator(RegexValidator): + def __call__(self, value): + if isinstance(value, str) and self.unsafe_chars.intersection(value): + raise ValidationError(self.message, code=self.code) ++ if not isinstance(value, str) or len(value) > self.max_length: ++ raise ValidationError(self.message, code=self.code, params={'value': value}) + # Check if the scheme is valid. + scheme = value.split('://')[0].lower() + if scheme not in self.schemes: +@@ -191,7 +194,9 @@ class EmailValidator: + self.domain_whitelist = whitelist + + 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) + + user_part, domain_part = value.rsplit('@', 1) +diff --git a/django/forms/fields.py b/django/forms/fields.py +index a97725652537..1185bfc77403 100644 +--- a/django/forms/fields.py ++++ b/django/forms/fields.py +@@ -523,6 +523,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/validators.txt b/docs/ref/validators.txt +index 75d1394f0d77..d4f43acc2849 100644 +--- a/docs/ref/validators.txt ++++ b/docs/ref/validators.txt +@@ -125,6 +125,11 @@ to, or in lieu of custom ``field.clean()`` methods. + :param code: If not ``None``, overrides :attr:`code`. + :param whitelist: If not ``None``, overrides :attr:`whitelist`. + ++ 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 +@@ -168,6 +173,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 826524ae629d..fe5b644ab31e 100644 +--- a/tests/forms_tests/field_tests/test_emailfield.py ++++ b/tests/forms_tests/field_tests/test_emailfield.py +@@ -8,7 +8,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 d4e421d6ac9a..8893f893924c 100644 +--- a/tests/forms_tests/tests/test_forms.py ++++ b/tests/forms_tests/tests/test_forms.py +@@ -422,11 +422,18 @@ class FormsTestCase(SimpleTestCase): + 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']), + '', +@@ -2780,7 +2787,7 @@ Good luck picking a username that doesn't already exist.

+ + + +-
  • ++
  • +
    • This field is required.
    +
  • """ + ) +@@ -2796,7 +2803,7 @@ Good luck picking a username that doesn't already exist.

    + + +

    +-

    ++

    +
    • This field is required.
    +

    +

    """ +@@ -2815,7 +2822,7 @@ Good luck picking a username that doesn't already exist.

    + + + +- ++ + +
    • This field is required.
    + """ +@@ -3428,7 +3435,7 @@ Good luck picking a username that doesn't already exist.

    + 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 1f09fb53fc5f..8204f00c31be 100644 +--- a/tests/validators/tests.py ++++ b/tests/validators/tests.py +@@ -58,6 +58,7 @@ TEST_DATA = [ + + (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), +@@ -242,6 +243,16 @@ TEST_DATA = [ + (URLValidator(EXTENDED_SCHEMES), 'git+ssh://git@github.com/example/hg-git.git', None), + + (URLValidator(EXTENDED_SCHEMES), 'git://-invalid.com', 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), diff -Nru python-django-2.2.28/debian/patches/series python-django-2.2.28/debian/patches/series --- python-django-2.2.28/debian/patches/series 2022-10-14 17:02:41.000000000 +0000 +++ python-django-2.2.28/debian/patches/series 2023-07-28 13:19:58.000000000 +0000 @@ -4,6 +4,11 @@ 0004-Set-the-default-shebang-to-new-projects-to-use-Pytho.patch 0005-Use-usr-bin-env-python3-shebang-for-django-admin.py.patch 0006-Moved-RequestSite-import-to-the-toplevel.patch +0007-fix-url-validator.patch CVE-2022-34265.patch CVE-2022-36359.patch CVE-2022-41323.patch +CVE-2023-36053.patch +CVE-2023-31047.patch +CVE-2023-24580.patch +CVE-2023-23969.patch