diff options
| author | Natalia <124304+nessita@users.noreply.github.com> | 2026-03-05 14:41:44 -0300 |
|---|---|---|
| committer | Jacob Walls <jacobtylerwalls@gmail.com> | 2026-04-07 07:12:23 -0400 |
| commit | 7e9885f99cee771b51692fadc5592bdbf19641aa (patch) | |
| tree | 9ead65503a5dec345db8c56e5b0d84b768c91e35 /tests | |
| parent | 6afe7ce93964f56e33a29d477c269436f9b60cbf (diff) | |
Fixed CVE-2026-33033 -- Mitigated potential DoS in MultiPartParser.
When a multipart file part used `Content-Transfer-Encoding: base64` and
the non-whitespace base64 bytes did not align to a multiple of 4 within
a chunk, the parser entered a loop calling `field_stream.read(1-3)` once
per whitespace byte. Each such call fetched the entire internal buffer,
sliced off 1-3 bytes, and pushed the remainder back via unget(), doing
an O(n) memory copy per call. A 2.5 MB payload of mostly whitespace
produced CPU amplification relative to a normal upload of the same size.
The alignment loop now reads `self._chunk_size` bytes at a time, and
accumulates stripped parts in a list joined once at the end.
Thanks to Seokchan Yoon for the report and the fixing patch.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/requests_tests/tests.py | 61 |
1 files changed, 61 insertions, 0 deletions
diff --git a/tests/requests_tests/tests.py b/tests/requests_tests/tests.py index e1744bf180..b15d954cb3 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 @@ -15,6 +16,7 @@ from django.http import ( ) from django.http.multipartparser import ( MAX_TOTAL_HEADER_SIZE, + LazyStream, MultiPartParser, MultiPartParserError, ) @@ -917,6 +919,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 |
