diff options
| author | Jacob Walls <jacobtylerwalls@gmail.com> | 2026-03-24 15:53:27 -0400 |
|---|---|---|
| committer | Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> | 2026-05-05 14:33:27 +0200 |
| commit | 5a89e341bfc77dd67b7fd57b7091b6430558e1f4 (patch) | |
| tree | eda11f2a0d23e74eb8ff6111a44ee2c0d19990fa | |
| parent | d75d57c2c06934a65feabd206129b9d1a230a1da (diff) | |
Fixed CVE-2026-5766 -- Enforced DATA_UPLOAD_MAX_MEMORY_SIZE in MemoryFileUploadHandler on ASGI.
In ASGI deployments, Content-Length is not guaranteed to reflect the
actual request body size, so relying on it to gate memory allocation
allowed the limit to be bypassed. The handler now enforces
DATA_UPLOAD_MAX_MEMORY_SIZE regardless of the declared header value.
Thanks to Kyle Agronick for the report. Refs #35289.
Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>
| -rw-r--r-- | django/core/files/uploadhandler.py | 21 | ||||
| -rw-r--r-- | docs/releases/5.2.14.txt | 14 | ||||
| -rw-r--r-- | docs/releases/6.0.5.txt | 14 | ||||
| -rw-r--r-- | tests/asgi/tests.py | 32 | ||||
| -rw-r--r-- | tests/requests_tests/tests.py | 38 |
5 files changed, 114 insertions, 5 deletions
diff --git a/django/core/files/uploadhandler.py b/django/core/files/uploadhandler.py index 133c0a597f..e8347b4637 100644 --- a/django/core/files/uploadhandler.py +++ b/django/core/files/uploadhandler.py @@ -3,7 +3,7 @@ Base file upload handler classes, and the built-in concrete subclasses """ import os -from io import BytesIO +from io import BytesIO, UnsupportedOperation from django.conf import settings from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile @@ -203,9 +203,24 @@ class MemoryFileUploadHandler(FileUploadHandler): Use the content_length to signal whether or not this handler should be used. """ - # Check the content-length header to see if we should # If the post is too large, we cannot use the Memory handler. - self.activated = content_length <= settings.FILE_UPLOAD_MAX_MEMORY_SIZE + # Content-Length can be absent or understated (for example + # `Transfer-Encoding: chunked` on ASGI), so for seekable streams (such + # as SpooledTemporaryFile on ASGI), check the actual size. + + stream = getattr(input_data, "_stream", input_data) + try: + content_length = stream.seek(0, os.SEEK_END) + except (UnsupportedOperation, AttributeError): + # Cannot seek; fall back to the Content-Length parameter. + # On WSGI the stream enforces this value so it is trustworthy. + pass + else: + stream.seek(0) + self.activated = ( + content_length is not None + and content_length <= settings.FILE_UPLOAD_MAX_MEMORY_SIZE + ) def new_file(self, *args, **kwargs): super().new_file(*args, **kwargs) diff --git a/docs/releases/5.2.14.txt b/docs/releases/5.2.14.txt index 681687efb1..0722e80a95 100644 --- a/docs/releases/5.2.14.txt +++ b/docs/releases/5.2.14.txt @@ -5,3 +5,17 @@ Django 5.2.14 release notes *May 5, 2026* Django 5.2.14 fixes three security issues with severity "low" in 5.2.13. + +CVE-2026-5766: Potential denial-of-service vulnerability in ASGI requests via file upload limit bypass +====================================================================================================== + +ASGI requests with a missing or understated ``Content-Length`` header could +bypass the :setting:`FILE_UPLOAD_MAX_MEMORY_SIZE` limit, potentially loading +large files into memory and causing service degradation. + +As a reminder, Django :ref:`expects a limit to be configured +<user-uploaded-content-security>` at the web server level rather than solely +relying on :setting:`FILE_UPLOAD_MAX_MEMORY_SIZE`. + +This issue has severity "low" according to the :ref:`Django security policy +<security-disclosure>`. diff --git a/docs/releases/6.0.5.txt b/docs/releases/6.0.5.txt index 1e7e1b2c3b..8613b604fb 100644 --- a/docs/releases/6.0.5.txt +++ b/docs/releases/6.0.5.txt @@ -7,6 +7,20 @@ Django 6.0.5 release notes Django 6.0.5 fixes three security issues with severity "low" and several bugs in 6.0.4. +CVE-2026-5766: Potential denial-of-service vulnerability in ASGI requests via file upload limit bypass +====================================================================================================== + +ASGI requests with a missing or understated ``Content-Length`` header could +bypass the :setting:`FILE_UPLOAD_MAX_MEMORY_SIZE` limit, potentially loading +large files into memory and causing service degradation. + +As a reminder, Django :ref:`expects a limit to be configured +<user-uploaded-content-security>` at the web server level rather than solely +relying on :setting:`FILE_UPLOAD_MAX_MEMORY_SIZE`. + +This issue has severity "low" according to the :ref:`Django security policy +<security-disclosure>`. + Bugfixes ======== diff --git a/tests/asgi/tests.py b/tests/asgi/tests.py index f77bd997a4..1ff6892078 100644 --- a/tests/asgi/tests.py +++ b/tests/asgi/tests.py @@ -13,6 +13,7 @@ from asgiref.testing import ApplicationCommunicator from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler from django.core.asgi import get_asgi_application from django.core.exceptions import RequestDataTooBig +from django.core.files.uploadedfile import InMemoryUploadedFile from django.core.handlers.asgi import ASGIHandler, ASGIRequest from django.core.signals import request_finished, request_started from django.db import close_old_connections @@ -804,8 +805,7 @@ class ASGITest(SimpleTestCase): self.assertEqual(request.COOKIES, {"a": "abc", "b": "def", "c": "ghi"}) -class DataUploadMaxMemorySizeASGITests(SimpleTestCase): - +class MaxMemorySizeASGITests(SimpleTestCase): def make_request( self, body, @@ -923,6 +923,34 @@ class DataUploadMaxMemorySizeASGITests(SimpleTestCase): self.addCleanup(uploaded.close) self.assertEqual(uploaded.read(), file_content) + def test_multipart_file_upload_limited_by_file_upload_max(self): + boundary = "testboundary" + file_content = b"x" * 100 + body = ( + ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n' + f"Content-Type: application/octet-stream\r\n" + f"\r\n" + ).encode() + + file_content + + f"\r\n--{boundary}--\r\n".encode() + ) + # Provide an understated content-length. + request = self.make_request( + body, + content_type=f"multipart/form-data; boundary={boundary}".encode(), + content_length=9, + ) + with self.settings(FILE_UPLOAD_MAX_MEMORY_SIZE=10): + files = request.FILES + self.assertEqual(len(files), 1) + uploaded = files["file"] + # The file is not loaded into memory. + self.assertNotIsInstance(uploaded, InMemoryUploadedFile) + self.addCleanup(uploaded.close) + self.assertEqual(uploaded.read(), file_content) + async def test_read_body_buffers_all_chunks(self): # read_body() consumes all chunks regardless of # DATA_UPLOAD_MAX_MEMORY_SIZE; the limit is enforced later when diff --git a/tests/requests_tests/tests.py b/tests/requests_tests/tests.py index c25dd913cf..cf24ddd326 100644 --- a/tests/requests_tests/tests.py +++ b/tests/requests_tests/tests.py @@ -1265,6 +1265,44 @@ class RequestsTests(SimpleTestCase): request.multipart_parser_class = MultiPartParser +class MemoryFileUploadHandlerTests(SimpleTestCase): + def test_handle_raw_input_wsgi_request_within_limit_activated(self): + + class WSGIRequest: + def __init__(self, body): + self._stream = LimitedStream(BytesIO(body), len(body)) + + handler = MemoryFileUploadHandler() + with self.settings(FILE_UPLOAD_MAX_MEMORY_SIZE=10): + handler.handle_raw_input(WSGIRequest(b"x" * 5), {}, 5, None) + self.assertIs(handler.activated, True) + + def test_handle_raw_input_wsgi_request_exceeds_limit_deactivated(self): + + class WSGIRequest: + def __init__(self, body): + self._stream = LimitedStream(BytesIO(body), len(body)) + + handler = MemoryFileUploadHandler() + with self.settings(FILE_UPLOAD_MAX_MEMORY_SIZE=10): + handler.handle_raw_input(WSGIRequest(b"x" * 15), {}, 15, None) + self.assertIs(handler.activated, False) + + def test_handle_raw_input_seekable_within_limit_activated(self): + handler = MemoryFileUploadHandler() + with self.settings(FILE_UPLOAD_MAX_MEMORY_SIZE=10): + # content_length param is understated (0) but actual size is 10. + handler.handle_raw_input(BytesIO(b"x" * 10), {}, 0, None) + self.assertIs(handler.activated, True) + + def test_handle_raw_input_seekable_exceeds_limit_deactivated(self): + handler = MemoryFileUploadHandler() + with self.settings(FILE_UPLOAD_MAX_MEMORY_SIZE=10): + # content_length param is understated (0) but actual size is 15. + handler.handle_raw_input(BytesIO(b"x" * 15), {}, 0, None) + self.assertIs(handler.activated, False) + + class HostValidationTests(SimpleTestCase): poisoned_hosts = [ "example.com@evil.tld", |
