diff options
| -rw-r--r-- | django/http/multipartparser.py | 15 | ||||
| -rw-r--r-- | docs/releases/4.2.30.txt | 10 | ||||
| -rw-r--r-- | docs/releases/5.2.13.txt | 10 | ||||
| -rw-r--r-- | docs/releases/6.0.4.txt | 10 | ||||
| -rw-r--r-- | tests/requests_tests/tests.py | 66 |
5 files changed, 104 insertions, 7 deletions
diff --git a/django/http/multipartparser.py b/django/http/multipartparser.py index d420c255eb..e3cf3ecc3b 100644 --- a/django/http/multipartparser.py +++ b/django/http/multipartparser.py @@ -305,15 +305,18 @@ class MultiPartParser: # We should always decode base64 chunks by # multiple of 4, ignoring whitespace. - stripped_chunk = b"".join(chunk.split()) + stripped_parts = [b"".join(chunk.split())] + stripped_length = len(stripped_parts[0]) - remaining = len(stripped_chunk) % 4 - while remaining != 0: - over_chunk = field_stream.read(4 - remaining) + while stripped_length % 4 != 0: + over_chunk = field_stream.read(self._chunk_size) if not over_chunk: break - stripped_chunk += b"".join(over_chunk.split()) - remaining = len(stripped_chunk) % 4 + over_stripped = b"".join(over_chunk.split()) + stripped_parts.append(over_stripped) + stripped_length += len(over_stripped) + + stripped_chunk = b"".join(stripped_parts) try: chunk = base64.b64decode(stripped_chunk) diff --git a/docs/releases/4.2.30.txt b/docs/releases/4.2.30.txt index de19a6f08f..c5058d9b84 100644 --- a/docs/releases/4.2.30.txt +++ b/docs/releases/4.2.30.txt @@ -46,3 +46,13 @@ instances to be created via forged ``POST`` data. This issue has severity "low" according to the :ref:`Django security policy <security-disclosure>`. + +CVE-2026-33033: Potential denial-of-service vulnerability in ``MultiPartParser`` via base64-encoded file upload +=============================================================================================================== + +When using ``django.http.multipartparser.MultiPartParser``, multipart uploads +with ``Content-Transfer-Encoding: base64`` that include excessive whitespace +may trigger repeated memory copying, potentially degrading performance. + +This issue has severity "moderate" according to the :ref:`Django security +policy <security-disclosure>`. diff --git a/docs/releases/5.2.13.txt b/docs/releases/5.2.13.txt index 8b303f2700..46303da3c7 100644 --- a/docs/releases/5.2.13.txt +++ b/docs/releases/5.2.13.txt @@ -46,3 +46,13 @@ instances to be created via forged ``POST`` data. This issue has severity "low" according to the :ref:`Django security policy <security-disclosure>`. + +CVE-2026-33033: Potential denial-of-service vulnerability in ``MultiPartParser`` via base64-encoded file upload +=============================================================================================================== + +When using ``django.http.multipartparser.MultiPartParser``, multipart uploads +with ``Content-Transfer-Encoding: base64`` that include excessive whitespace +may trigger repeated memory copying, potentially degrading performance. + +This issue has severity "moderate" according to the :ref:`Django security +policy <security-disclosure>`. diff --git a/docs/releases/6.0.4.txt b/docs/releases/6.0.4.txt index 4287a3086a..6452768f2a 100644 --- a/docs/releases/6.0.4.txt +++ b/docs/releases/6.0.4.txt @@ -47,6 +47,16 @@ instances to be created via forged ``POST`` data. This issue has severity "low" according to the :ref:`Django security policy <security-disclosure>`. +CVE-2026-33033: Potential denial-of-service vulnerability in ``MultiPartParser`` via base64-encoded file upload +=============================================================================================================== + +When using ``django.http.multipartparser.MultiPartParser``, multipart uploads +with ``Content-Transfer-Encoding: base64`` that include excessive whitespace +may trigger repeated memory copying, potentially degrading performance. + +This issue has severity "moderate" according to the :ref:`Django security +policy <security-disclosure>`. + Bugfixes ======== diff --git a/tests/requests_tests/tests.py b/tests/requests_tests/tests.py index 36843df9b6..25157814cf 100644 --- a/tests/requests_tests/tests.py +++ b/tests/requests_tests/tests.py @@ -1,6 +1,7 @@ import copy from io import BytesIO from itertools import chain +from unittest import mock from urllib.parse import urlencode from django.core.exceptions import BadRequest, DisallowedHost @@ -13,7 +14,11 @@ from django.http import ( RawPostDataException, UnreadablePostError, ) -from django.http.multipartparser import MAX_TOTAL_HEADER_SIZE, MultiPartParserError +from django.http.multipartparser import ( + MAX_TOTAL_HEADER_SIZE, + LazyStream, + MultiPartParserError, +) from django.http.request import split_domain_port from django.test import RequestFactory, SimpleTestCase, override_settings from django.test.client import BOUNDARY, MULTIPART_CONTENT, FakePayload @@ -906,6 +911,65 @@ class RequestsTests(SimpleTestCase): request.body # evaluate self.assertEqual(request.POST, {"name": ["123"]}) + def test_multipart_file_upload_base64_whitespace_heavy(self): + # Fake a file upload with base64-encoded content including mostly + # whitespaces across chunk boundaries. + payload = FakePayload( + "\r\n".join( + [ + f"--{BOUNDARY}", + 'Content-Disposition: form-data; name="file"; filename="test.txt"', + "Content-Type: application/octet-stream", + "Content-Transfer-Encoding: base64", + "", + ] + ) + ) + # "AAAA" decodes to b"\x00\x00\x00". Whitespace (70000 bytes) spans the + # default 64KB chunk boundary, hence the alignment loop is exercised. + payload.write(b"\r\n" + b"AAA" + b" " * 70000 + b"A" + b"\r\n") + payload.write("--" + BOUNDARY + "--\r\n") + request = WSGIRequest( + { + "REQUEST_METHOD": "POST", + "CONTENT_TYPE": MULTIPART_CONTENT, + "CONTENT_LENGTH": len(payload), + "wsgi.input": payload, + } + ) + reads = [] + original_read = LazyStream.read + + def counting_read(self_stream, size=None): + reads.append(size) + return original_read(self_stream, size) + + with mock.patch.object(LazyStream, "read", counting_read): + files = request.FILES + + self.assertEqual(len(files), 1) + self.assertEqual(files["file"].read(), b"\x00\x00\x00") + + # The alignment loop must read in `chunk-sized` units rather than one + # byte at a time, otherwise each whitespace byte triggers a separate + # read() call with a costly internal unget() cycle. + # Parsing this payload should issue exactly 8 LazyStream.read() calls: + # 1. main_stream.read(1) -- BoundaryIter.__init__ probe, preamble + # 2. sub_stream.read(1024) -- parse_boundary_stream, preamble headers + # 3. main_stream.read(1) -- BoundaryIter.__init__ probe, file field + # 4. field_stream.read(1024) -- parse_boundary_stream, file headers + # 5. field_stream.read(65536)-- base64 alignment loop: one chunk-sized + # read to find the non-whitespace bytes + # needed to complete the 4-byte base64 + # group that spans the chunk boundary + # 6. main_stream.read(1) -- BoundaryIter.__init__ probe, epilogue + # 7. sub_stream.read(1024) -- parse_boundary_stream, epilogue headers + # 8. main_stream.read(1) -- BoundaryIter.__init__ probe, exhausted + # stream; returns b"" and stops iteration + # A byte-at-a-time implementation of read() in step 5 would do instead + # one read(1) per whitespace byte past the chunk boundary (4488 calls). + self.assertEqual(reads, [1, 1024, 1, 1024, 65536, 1, 1024, 1]) + def test_POST_after_body_read_and_stream_read_multipart(self): """ POST should be populated even if body is read first, and then |
