Skip to content

Commit 7e9885f

Browse files
nessitajacobtylerwalls
authored andcommitted
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.
1 parent 6afe7ce commit 7e9885f

5 files changed

Lines changed: 100 additions & 6 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: 61 additions & 0 deletions
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
@@ -15,6 +16,7 @@
1516
)
1617
from django.http.multipartparser import (
1718
MAX_TOTAL_HEADER_SIZE,
19+
LazyStream,
1820
MultiPartParser,
1921
MultiPartParserError,
2022
)
@@ -917,6 +919,65 @@ def test_multipart_post_field_with_invalid_base64(self):
917919
request.body # evaluate
918920
self.assertEqual(request.POST, {"name": ["123"]})
919921

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

0 commit comments

Comments
 (0)