diff --git a/django/contrib/sessions/middleware.py b/django/contrib/sessions/middleware.py index 9c934f9dddab..c6a5e336788c 100644 --- a/django/contrib/sessions/middleware.py +++ b/django/contrib/sessions/middleware.py @@ -40,10 +40,11 @@ def process_response(self, request, response): domain=settings.SESSION_COOKIE_DOMAIN, samesite=settings.SESSION_COOKIE_SAMESITE, ) - patch_vary_headers(response, ("Cookie",)) + need_vary_cookie = True else: - if accessed: - patch_vary_headers(response, ("Cookie",)) + # If the session was accessed, it must be varied on, regardless of + # whether it was modified or will be saved. + need_vary_cookie = accessed if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty: if request.session.get_expire_at_browser_close(): max_age = None @@ -74,4 +75,8 @@ def process_response(self, request, response): httponly=settings.SESSION_COOKIE_HTTPONLY or None, samesite=settings.SESSION_COOKIE_SAMESITE, ) + # With a session cookie set, it must be varied on. + need_vary_cookie = True + if need_vary_cookie: + patch_vary_headers(response, ("Cookie",)) return response diff --git a/django/core/files/uploadhandler.py b/django/core/files/uploadhandler.py index 133c0a597fa0..e8347b4637ab 100644 --- a/django/core/files/uploadhandler.py +++ b/django/core/files/uploadhandler.py @@ -3,7 +3,7 @@ """ 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 @@ def handle_raw_input( 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/django/middleware/cache.py b/django/middleware/cache.py index 8c42a52b4afd..678fde68a891 100644 --- a/django/middleware/cache.py +++ b/django/middleware/cache.py @@ -113,6 +113,10 @@ def process_response(self, request, response): ): return response + # Don't cache responses when the Vary header contains '*'. + if has_vary_header(response, "*"): + return response + # Page timeout takes precedence over the "max-age" and the default # cache timeout. timeout = self.page_timeout diff --git a/docs/releases/5.2.14.txt b/docs/releases/5.2.14.txt index 681687efb185..1fe882ebea1f 100644 --- a/docs/releases/5.2.14.txt +++ b/docs/releases/5.2.14.txt @@ -5,3 +5,38 @@ 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 +` 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 +`. + +CVE-2026-35192: Session fixation via public cached pages and ``SESSION_SAVE_EVERY_REQUEST`` +=========================================================================================== + +Response headers did not :ref:`vary on ` cookies if a +session was not modified, but :setting:`SESSION_SAVE_EVERY_REQUEST` was +``True``. A remote attacker could steal a user's session after that user visits +a cached public page. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-6907: Potential exposure of private data due to incorrect handling of ``Vary: *`` in ``UpdateCacheMiddleware`` +======================================================================================================================= + +Previously, :class:`~django.middleware.cache.UpdateCacheMiddleware` would +erroneously cache requests where the ``Vary`` header contained an asterisk +(``'*'``). This could lead to private data being stored and served. + +This issue has severity "low" according to the :ref:`Django security policy +`. diff --git a/docs/releases/6.0.5.txt b/docs/releases/6.0.5.txt index 1e7e1b2c3b9b..08bce66e6a76 100644 --- a/docs/releases/6.0.5.txt +++ b/docs/releases/6.0.5.txt @@ -7,6 +7,41 @@ 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 +` 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 +`. + +CVE-2026-35192: Session fixation via public cached pages and ``SESSION_SAVE_EVERY_REQUEST`` +=========================================================================================== + +Response headers did not :ref:`vary on ` cookies if a +session was not modified, but :setting:`SESSION_SAVE_EVERY_REQUEST` was +``True``. A remote attacker could steal a user's session after that user visits +a cached public page. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-6907: Potential exposure of private data due to incorrect handling of ``Vary: *`` in ``UpdateCacheMiddleware`` +======================================================================================================================= + +Previously, :class:`~django.middleware.cache.UpdateCacheMiddleware` would +erroneously cache requests where the ``Vary`` header contained an asterisk +(``'*'``). This could lead to private data being stored and served. + +This issue has severity "low" according to the :ref:`Django security policy +`. + Bugfixes ======== diff --git a/docs/releases/6.0.6.txt b/docs/releases/6.0.6.txt new file mode 100644 index 000000000000..20501cddc4e4 --- /dev/null +++ b/docs/releases/6.0.6.txt @@ -0,0 +1,12 @@ +========================== +Django 6.0.6 release notes +========================== + +*Expected June 3, 2026* + +Django 6.0.6 fixes several bugs in 6.0.5. + +Bugfixes +======== + +* ... diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 05baf00ae0a6..b0ce65c7a7eb 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -32,6 +32,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 6.0.6 6.0.5 6.0.4 6.0.3 diff --git a/docs/releases/security.txt b/docs/releases/security.txt index b689d90f1de6..fc79d72d2595 100644 --- a/docs/releases/security.txt +++ b/docs/releases/security.txt @@ -36,6 +36,38 @@ Issues under Django's security process All security issues have been handled under versions of Django's security process. These are listed below. +May 5, 2026 - :cve:`2026-5766` +------------------------------ + +Potential denial-of-service vulnerability in ASGI requests via file upload +limit bypass. +`Full description +`__ + +* Django 6.0 :commit:`(patch) ` +* Django 5.2 :commit:`(patch) <2ec27eda3ba6c14f0856e6e3eb1df07c41fd95e6>` + +May 5, 2026 - :cve:`2026-35192` +------------------------------- + +Session fixation via public cached pages and ``SESSION_SAVE_EVERY_REQUEST``. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <1b0184aa657bc3f5859aeb0206e7c1e94e48b103>` +* Django 5.2 :commit:`(patch) <47cf968c125e3fab317e10fe150ec479e745f995>` + +May 5, 2026 - :cve:`2026-6907` +------------------------------ + +Potential exposure of private data due to incorrect handling of ``Vary: *`` in +``UpdateCacheMiddleware``. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <44ad76efcbe3c4ca0f08bb9dabe916f6374596c9>` +* Django 5.2 :commit:`(patch) <2115d4eaee15107f5cd290d7cfcc5ffe3ad43661>` + April 7, 2026 - :cve:`2026-3902` -------------------------------- diff --git a/tests/asgi/tests.py b/tests/asgi/tests.py index f77bd997a4aa..1ff68920782e 100644 --- a/tests/asgi/tests.py +++ b/tests/asgi/tests.py @@ -13,6 +13,7 @@ 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 @@ def test_multiple_cookie_headers_http2(self): 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 @@ def test_multipart_file_upload_not_limited_by_data_upload_max(self): 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/cache/tests.py b/tests/cache/tests.py index a94a8a63cd04..65ca88512584 100644 --- a/tests/cache/tests.py +++ b/tests/cache/tests.py @@ -2616,6 +2616,18 @@ def hello_world_view(request, value): return HttpResponse("Hello World %s" % value) +def hello_world_view_patch_vary_headers_asterisk(request, value): + response = HttpResponse("Hello World %s" % value) + patch_vary_headers(response, ("*",)) + return response + + +def hello_world_view_vary_headers_includes_asterisk(request, value): + response = HttpResponse("Hello World %s" % value) + response["Vary"] = "Cookie, *, Pony" + return response + + def csrf_view(request): return HttpResponse(csrf(request)["csrf_token"]) @@ -2850,6 +2862,19 @@ def test_cache_control_not_cached(self): response = view_with_cache(request, "2") self.assertEqual(response.content, b"Hello World 2") + def test_vary_asterisk_not_cached(self): + views_with_cache = ( + cache_page(3)(hello_world_view_patch_vary_headers_asterisk), + cache_page(3)(hello_world_view_vary_headers_includes_asterisk), + ) + for view in views_with_cache: + with self.subTest(view=view): + request = self.factory.get("/view/") + response = view(request, "1") + self.assertEqual(response.content, b"Hello World 1") + response = view(request, "2") + self.assertEqual(response.content, b"Hello World 2") + def test_sensitive_cookie_not_cached(self): """ Django must prevent caching of responses that set a user-specific (and diff --git a/tests/requests_tests/tests.py b/tests/requests_tests/tests.py index c25dd913cf47..cf24ddd326d3 100644 --- a/tests/requests_tests/tests.py +++ b/tests/requests_tests/tests.py @@ -1265,6 +1265,44 @@ def test_multipart_parser_class_immutable_after_parse(self): 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", diff --git a/tests/sessions_tests/tests.py b/tests/sessions_tests/tests.py index f23fa9778d16..1a4b114a8a68 100644 --- a/tests/sessions_tests/tests.py +++ b/tests/sessions_tests/tests.py @@ -1022,6 +1022,7 @@ def test_secure_session_cookie(self): # Handle the response through the middleware response = middleware(request) self.assertIs(response.cookies[settings.SESSION_COOKIE_NAME]["secure"], True) + self.assertEqual(response.headers["Vary"], "Cookie") @override_settings(SESSION_COOKIE_HTTPONLY=True) def test_httponly_session_cookie(self): @@ -1162,6 +1163,7 @@ def response_ending_session(request): ), str(response.cookies[settings.SESSION_COOKIE_NAME]), ) + self.assertEqual(response.headers["Vary"], "Cookie") def test_flush_empty_without_session_cookie_doesnt_set_cookie(self): def response_ending_session(request): @@ -1179,6 +1181,32 @@ def response_ending_session(request): # The session is accessed so "Vary: Cookie" should be set. self.assertEqual(response.headers["Vary"], "Cookie") + @override_settings(SESSION_SAVE_EVERY_REQUEST=True) + def test_save_every_request_with_non_empty_session_renews_session_cookie(self): + request = self.request_factory.get("/") + middleware = SessionMiddleware(self.get_response_touching_session) + + # Make sure the request has a session. + middleware(request) + + # A cookie should be set. + self.assertIs(request.session.is_empty(), False) + self.assertEqual(request.session["hello"], "world") + + request.COOKIES[settings.SESSION_COOKIE_NAME] = request.session.session_key + + def simple_view(request): + return HttpResponse("Session test") + + middleware = SessionMiddleware(simple_view) + response = middleware(request) + + # A cookie should be set because SESSION_SAVE_EVERY_REQUEST=True, + # even though the session wasn't touched. + self.assertIn(settings.SESSION_COOKIE_NAME, response.cookies) + # There's a session, so also Vary on it. + self.assertEqual(response.headers["Vary"], "Cookie") + def test_empty_session_saved(self): """ If a session is emptied of data but still has a key, it should still