Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 0910af6

Browse files
nessitajacobtylerwalls
authored andcommitted
[6.0.x] 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. Backport of 7e9885f from main.
1 parent 428c48f commit 0910af6

5 files changed

Lines changed: 104 additions & 7 deletions

File tree

django/http/multipartparser.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -305,15 +305,18 @@ def _parse(self):
305305
# We should always decode base64 chunks by
306306
# multiple of 4, ignoring whitespace.
307307

308-
stripped_chunk = b"".join(chunk.split())
308+
stripped_parts = [b"".join(chunk.split())]
309+
stripped_length = len(stripped_parts[0])
309310

310-
remaining = len(stripped_chunk) % 4
311-
while remaining != 0:
312-
over_chunk = field_stream.read(4 - remaining)
311+
while stripped_length % 4 != 0:
312+
over_chunk = field_stream.read(self._chunk_size)
313313
if not over_chunk:
314314
break
315-
stripped_chunk += b"".join(over_chunk.split())
316-
remaining = len(stripped_chunk) % 4
315+
over_stripped = b"".join(over_chunk.split())
316+
stripped_parts.append(over_stripped)
317+
stripped_length += len(over_stripped)
318+
319+
stripped_chunk = b"".join(stripped_parts)
317320

318321
try:
319322
chunk = base64.b64decode(stripped_chunk)

docs/releases/4.2.30.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,13 @@ instances to be created via forged ``POST`` data.
4646

4747
This issue has severity "low" according to the :ref:`Django security policy
4848
<security-disclosure>`.
49+
50+
CVE-2026-33033: Potential denial-of-service vulnerability in ``MultiPartParser`` via base64-encoded file upload
51+
===============================================================================================================
52+
53+
When using ``django.http.multipartparser.MultiPartParser``, multipart uploads
54+
with ``Content-Transfer-Encoding: base64`` that include excessive whitespace
55+
may trigger repeated memory copying, potentially degrading performance.
56+
57+
This issue has severity "moderate" according to the :ref:`Django security
58+
policy <security-disclosure>`.

docs/releases/5.2.13.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,13 @@ instances to be created via forged ``POST`` data.
4646

4747
This issue has severity "low" according to the :ref:`Django security policy
4848
<security-disclosure>`.
49+
50+
CVE-2026-33033: Potential denial-of-service vulnerability in ``MultiPartParser`` via base64-encoded file upload
51+
===============================================================================================================
52+
53+
When using ``django.http.multipartparser.MultiPartParser``, multipart uploads
54+
with ``Content-Transfer-Encoding: base64`` that include excessive whitespace
55+
may trigger repeated memory copying, potentially degrading performance.
56+
57+
This issue has severity "moderate" according to the :ref:`Django security
58+
policy <security-disclosure>`.

docs/releases/6.0.4.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ instances to be created via forged ``POST`` data.
4747
This issue has severity "low" according to the :ref:`Django security policy
4848
<security-disclosure>`.
4949

50+
CVE-2026-33033: Potential denial-of-service vulnerability in ``MultiPartParser`` via base64-encoded file upload
51+
===============================================================================================================
52+
53+
When using ``django.http.multipartparser.MultiPartParser``, multipart uploads
54+
with ``Content-Transfer-Encoding: base64`` that include excessive whitespace
55+
may trigger repeated memory copying, potentially degrading performance.
56+
57+
This issue has severity "moderate" according to the :ref:`Django security
58+
policy <security-disclosure>`.
59+
5060
Bugfixes
5161
========
5262

tests/requests_tests/tests.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import copy
22
from io import BytesIO
33
from itertools import chain
4+
from unittest import mock
45
from urllib.parse import urlencode
56

67
from django.core.exceptions import BadRequest, DisallowedHost
@@ -13,7 +14,11 @@
1314
RawPostDataException,
1415
UnreadablePostError,
1516
)
16-
from django.http.multipartparser import MAX_TOTAL_HEADER_SIZE, MultiPartParserError
17+
from django.http.multipartparser import (
18+
MAX_TOTAL_HEADER_SIZE,
19+
LazyStream,
20+
MultiPartParserError,
21+
)
1722
from django.http.request import split_domain_port
1823
from django.test import RequestFactory, SimpleTestCase, override_settings
1924
from django.test.client import BOUNDARY, MULTIPART_CONTENT, FakePayload
@@ -906,6 +911,65 @@ def test_multipart_post_field_with_invalid_base64(self):
906911
request.body # evaluate
907912
self.assertEqual(request.POST, {"name": ["123"]})
908913

914+
def test_multipart_file_upload_base64_whitespace_heavy(self):
915+
# Fake a file upload with base64-encoded content including mostly
916+
# whitespaces across chunk boundaries.
917+
payload = FakePayload(
918+
"\r\n".join(
919+
[
920+
f"--{BOUNDARY}",
921+
'Content-Disposition: form-data; name="file"; filename="test.txt"',
922+
"Content-Type: application/octet-stream",
923+
"Content-Transfer-Encoding: base64",
924+
"",
925+
]
926+
)
927+
)
928+
# "AAAA" decodes to b"\x00\x00\x00". Whitespace (70000 bytes) spans the
929+
# default 64KB chunk boundary, hence the alignment loop is exercised.
930+
payload.write(b"\r\n" + b"AAA" + b" " * 70000 + b"A" + b"\r\n")
931+
payload.write("--" + BOUNDARY + "--\r\n")
932+
request = WSGIRequest(
933+
{
934+
"REQUEST_METHOD": "POST",
935+
"CONTENT_TYPE": MULTIPART_CONTENT,
936+
"CONTENT_LENGTH": len(payload),
937+
"wsgi.input": payload,
938+
}
939+
)
940+
reads = []
941+
original_read = LazyStream.read
942+
943+
def counting_read(self_stream, size=None):
944+
reads.append(size)
945+
return original_read(self_stream, size)
946+
947+
with mock.patch.object(LazyStream, "read", counting_read):
948+
files = request.FILES
949+
950+
self.assertEqual(len(files), 1)
951+
self.assertEqual(files["file"].read(), b"\x00\x00\x00")
952+
953+
# The alignment loop must read in `chunk-sized` units rather than one
954+
# byte at a time, otherwise each whitespace byte triggers a separate
955+
# read() call with a costly internal unget() cycle.
956+
# Parsing this payload should issue exactly 8 LazyStream.read() calls:
957+
# 1. main_stream.read(1) -- BoundaryIter.__init__ probe, preamble
958+
# 2. sub_stream.read(1024) -- parse_boundary_stream, preamble headers
959+
# 3. main_stream.read(1) -- BoundaryIter.__init__ probe, file field
960+
# 4. field_stream.read(1024) -- parse_boundary_stream, file headers
961+
# 5. field_stream.read(65536)-- base64 alignment loop: one chunk-sized
962+
# read to find the non-whitespace bytes
963+
# needed to complete the 4-byte base64
964+
# group that spans the chunk boundary
965+
# 6. main_stream.read(1) -- BoundaryIter.__init__ probe, epilogue
966+
# 7. sub_stream.read(1024) -- parse_boundary_stream, epilogue headers
967+
# 8. main_stream.read(1) -- BoundaryIter.__init__ probe, exhausted
968+
# stream; returns b"" and stops iteration
969+
# A byte-at-a-time implementation of read() in step 5 would do instead
970+
# one read(1) per whitespace byte past the chunk boundary (4488 calls).
971+
self.assertEqual(reads, [1, 1024, 1, 1024, 65536, 1, 1024, 1])
972+
909973
def test_POST_after_body_read_and_stream_read_multipart(self):
910974
"""
911975
POST should be populated even if body is read first, and then

0 commit comments

Comments
 (0)