Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions django/contrib/sessions/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
21 changes: 18 additions & 3 deletions django/core/files/uploadhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions django/middleware/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions docs/releases/5.2.14.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
<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>`.

CVE-2026-35192: Session fixation via public cached pages and ``SESSION_SAVE_EVERY_REQUEST``
===========================================================================================

Response headers did not :ref:`vary on <using-vary-headers>` 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
<security-disclosure>`.

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
<security-disclosure>`.
35 changes: 35 additions & 0 deletions docs/releases/6.0.5.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
<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>`.

CVE-2026-35192: Session fixation via public cached pages and ``SESSION_SAVE_EVERY_REQUEST``
===========================================================================================

Response headers did not :ref:`vary on <using-vary-headers>` 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
<security-disclosure>`.

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
<security-disclosure>`.

Bugfixes
========

Expand Down
12 changes: 12 additions & 0 deletions docs/releases/6.0.6.txt
Original file line number Diff line number Diff line change
@@ -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
========

* ...
1 change: 1 addition & 0 deletions docs/releases/index.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions docs/releases/security.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
<https://www.djangoproject.com/weblog/2026/may/05/security-releases/>`__

* Django 6.0 :commit:`(patch) <ad8f9e19e0897ea45ded7c046ff28daf6f773e92>`
* 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
<https://www.djangoproject.com/weblog/2026/may/05/security-releases/>`__

* 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
<https://www.djangoproject.com/weblog/2026/may/05/security-releases/>`__

* Django 6.0 :commit:`(patch) <44ad76efcbe3c4ca0f08bb9dabe916f6374596c9>`
* Django 5.2 :commit:`(patch) <2115d4eaee15107f5cd290d7cfcc5ffe3ad43661>`

April 7, 2026 - :cve:`2026-3902`
--------------------------------

Expand Down
32 changes: 30 additions & 2 deletions tests/asgi/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions tests/cache/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions tests/requests_tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions tests/sessions_tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down
Loading