티스토리 수익 글 보기

티스토리 수익 글 보기

[4.2.x] Fixed CVE-2026-33033 — Mitigated potential DoS in MultiPartP… · django/django@f13c20f · GitHub
Skip to content

Commit f13c20f

Browse files
nessitajacobtylerwalls
authored andcommitted
[4.2.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 abfe1a1 commit f13c20f

3 files changed

Lines changed: 131 additions & 8 deletions

File tree

django/http/multipartparser.py

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

305-
stripped_chunk = b"".join(chunk.split())
305+
stripped_parts = [b"".join(chunk.split())]
306+
stripped_length = len(stripped_parts[0])
306307

307-
remaining = len(stripped_chunk) % 4
308-
while remaining != 0:
309-
over_chunk = field_stream.read(4 - remaining)
308+
while stripped_length % 4 != 0:
309+
over_chunk = field_stream.read(self._chunk_size)
310310
if not over_chunk:
311311
break
312-
stripped_chunk += b"".join(over_chunk.split())
313-
remaining = len(stripped_chunk) % 4
312+
over_stripped = b"".join(over_chunk.split())
313+
stripped_parts.append(over_stripped)
314+
stripped_length += len(over_stripped)
315+
316+
stripped_chunk = b"".join(stripped_parts)
314317

315318
try:
316319
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>`.

tests/requests_tests/tests.py

Lines changed: 112 additions & 2 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 DisallowedHost
@@ -11,10 +12,10 @@
1112
RawPostDataException,
1213
UnreadablePostError,
1314
)
14-
from django.http.multipartparser import MultiPartParserError
15+
from django.http.multipartparser import LazyStream, MultiPartParserError
1516
from django.http.request import split_domain_port
1617
from django.test import RequestFactory, SimpleTestCase, override_settings
17-
from django.test.client import FakePayload
18+
from django.test.client import BOUNDARY, MULTIPART_CONTENT, FakePayload
1819

1920

2021
class RequestsTests(SimpleTestCase):
@@ -537,6 +538,115 @@ def test_POST_after_body_read_and_stream_read(self):
537538
self.assertEqual(request.read(1), b"n")
538539
self.assertEqual(request.POST, {"name": ["value"]})
539540

541+
def test_multipart_post_field_with_base64(self):
542+
payload = FakePayload(
543+
"\r\n".join(
544+
[
545+
f"--{BOUNDARY}",
546+
'Content-Disposition: form-data; name="name"',
547+
"Content-Transfer-Encoding: base64",
548+
"",
549+
"dmFsdWU=",
550+
f"--{BOUNDARY}--",
551+
"",
552+
]
553+
)
554+
)
555+
request = WSGIRequest(
556+
{
557+
"REQUEST_METHOD": "POST",
558+
"CONTENT_TYPE": MULTIPART_CONTENT,
559+
"CONTENT_LENGTH": len(payload),
560+
"wsgi.input": payload,
561+
}
562+
)
563+
request.body # evaluate
564+
self.assertEqual(request.POST, {"name": ["value"]})
565+
566+
def test_multipart_post_field_with_invalid_base64(self):
567+
payload = FakePayload(
568+
"\r\n".join(
569+
[
570+
f"--{BOUNDARY}",
571+
'Content-Disposition: form-data; name="name"',
572+
"Content-Transfer-Encoding: base64",
573+
"",
574+
"123",
575+
f"--{BOUNDARY}--",
576+
"",
577+
]
578+
)
579+
)
580+
request = WSGIRequest(
581+
{
582+
"REQUEST_METHOD": "POST",
583+
"CONTENT_TYPE": MULTIPART_CONTENT,
584+
"CONTENT_LENGTH": len(payload),
585+
"wsgi.input": payload,
586+
}
587+
)
588+
request.body # evaluate
589+
self.assertEqual(request.POST, {"name": ["123"]})
590+
591+
def test_multipart_file_upload_base64_whitespace_heavy(self):
592+
# Fake a file upload with base64-encoded content including mostly
593+
# whitespaces across chunk boundaries.
594+
payload = FakePayload(
595+
"\r\n".join(
596+
[
597+
f"--{BOUNDARY}",
598+
'Content-Disposition: form-data; name="file"; filename="test.txt"',
599+
"Content-Type: application/octet-stream",
600+
"Content-Transfer-Encoding: base64",
601+
"",
602+
]
603+
)
604+
)
605+
# "AAAA" decodes to b"\x00\x00\x00". Whitespace (70000 bytes) spans the
606+
# default 64KB chunk boundary, hence the alignment loop is exercised.
607+
payload.write(b"\r\n" + b"AAA" + b" " * 70000 + b"A" + b"\r\n")
608+
payload.write("--" + BOUNDARY + "--\r\n")
609+
request = WSGIRequest(
610+
{
611+
"REQUEST_METHOD": "POST",
612+
"CONTENT_TYPE": MULTIPART_CONTENT,
613+
"CONTENT_LENGTH": len(payload),
614+
"wsgi.input": payload,
615+
}
616+
)
617+
reads = []
618+
original_read = LazyStream.read
619+
620+
def counting_read(self_stream, size=None):
621+
reads.append(size)
622+
return original_read(self_stream, size)
623+
624+
with mock.patch.object(LazyStream, "read", counting_read):
625+
files = request.FILES
626+
627+
self.assertEqual(len(files), 1)
628+
self.assertEqual(files["file"].read(), b"\x00\x00\x00")
629+
630+
# The alignment loop must read in `chunk-sized` units rather than one
631+
# byte at a time, otherwise each whitespace byte triggers a separate
632+
# read() call with a costly internal unget() cycle.
633+
# Parsing this payload should issue exactly 8 LazyStream.read() calls:
634+
# 1. main_stream.read(1) -- BoundaryIter.__init__ probe, preamble
635+
# 2. sub_stream.read(1024) -- parse_boundary_stream, preamble headers
636+
# 3. main_stream.read(1) -- BoundaryIter.__init__ probe, file field
637+
# 4. field_stream.read(1024) -- parse_boundary_stream, file headers
638+
# 5. field_stream.read(65536)-- base64 alignment loop: one chunk-sized
639+
# read to find the non-whitespace bytes
640+
# needed to complete the 4-byte base64
641+
# group that spans the chunk boundary
642+
# 6. main_stream.read(1) -- BoundaryIter.__init__ probe, epilogue
643+
# 7. sub_stream.read(1024) -- parse_boundary_stream, epilogue headers
644+
# 8. main_stream.read(1) -- BoundaryIter.__init__ probe, exhausted
645+
# stream; returns b"" and stops iteration
646+
# A byte-at-a-time implementation of read() in step 5 would do instead
647+
# one read(1) per whitespace byte past the chunk boundary (4488 calls).
648+
self.assertEqual(reads, [1, 1024, 1, 1024, 65536, 1, 1024, 1])
649+
540650
def test_POST_after_body_read_and_stream_read_multipart(self):
541651
"""
542652
POST should be populated even if body is read first, and then

0 commit comments

Comments
 (0)