diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index c59cb2ab4cf6..4841661f16ec 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -34,6 +34,7 @@ from django.contrib.admin.widgets import AutocompleteSelect, AutocompleteSelectMultiple from django.contrib.auth import get_permission_codename from django.core.exceptions import ( + BadRequest, FieldDoesNotExist, FieldError, PermissionDenied, @@ -2119,6 +2120,8 @@ def changelist_view(self, request, extra_context=None): for form in cl.formset.forms: if form.has_changed(): obj = self.save_form(request, form, change=True) + if obj._state.adding: + raise BadRequest("list_editable does not allow adding.") self.save_model(request, obj, form, change=True) self.save_related(request, form, formsets=[], change=True) change_msg = self.construct_change_message( diff --git a/django/contrib/contenttypes/admin.py b/django/contrib/contenttypes/admin.py index f595ce528527..d324a4f4fe50 100644 --- a/django/contrib/contenttypes/admin.py +++ b/django/contrib/contenttypes/admin.py @@ -127,6 +127,21 @@ def get_formset(self, request, obj=None, **kwargs): **kwargs, } + base_model_form = defaults["form"] + can_change = self.has_change_permission(request, obj) if request else True + can_add = self.has_add_permission(request, obj) if request else True + + class PermissionProtectedModelForm(base_model_form): + def has_changed(self): + # Protect against unauthorized edits. + if not can_change and not self.instance._state.adding: + return False + if not can_add and self.instance._state.adding: + return False + return super().has_changed() + + defaults["form"] = PermissionProtectedModelForm + if defaults["fields"] is None and not modelform_defines_fields( defaults["form"] ): diff --git a/django/core/handlers/asgi.py b/django/core/handlers/asgi.py index 9555860a7e21..7ee52088c416 100644 --- a/django/core/handlers/asgi.py +++ b/django/core/handlers/asgi.py @@ -90,6 +90,9 @@ def __init__(self, scope, body_file): _headers = defaultdict(list) for name, value in self.scope.get("headers", []): name = name.decode("latin1") + # Prevent spoofing via ambiguity between underscores and hyphens. + if "_" in name: + continue if name == "content-length": corrected_name = "CONTENT_LENGTH" elif name == "content-type": diff --git a/django/http/multipartparser.py b/django/http/multipartparser.py index b834b8b31b0d..1195b056d955 100644 --- a/django/http/multipartparser.py +++ b/django/http/multipartparser.py @@ -305,15 +305,18 @@ def _parse(self): # We should always decode base64 chunks by # multiple of 4, ignoring whitespace. - stripped_chunk = b"".join(chunk.split()) + stripped_parts = [b"".join(chunk.split())] + stripped_length = len(stripped_parts[0]) - remaining = len(stripped_chunk) % 4 - while remaining != 0: - over_chunk = field_stream.read(4 - remaining) + while stripped_length % 4 != 0: + over_chunk = field_stream.read(self._chunk_size) if not over_chunk: break - stripped_chunk += b"".join(over_chunk.split()) - remaining = len(stripped_chunk) % 4 + over_stripped = b"".join(over_chunk.split()) + stripped_parts.append(over_stripped) + stripped_length += len(over_stripped) + + stripped_chunk = b"".join(stripped_parts) try: chunk = base64.b64decode(stripped_chunk) diff --git a/django/http/request.py b/django/http/request.py index 573ae2b229d6..f871ea15e88a 100644 --- a/django/http/request.py +++ b/django/http/request.py @@ -1,6 +1,7 @@ import codecs import copy import operator +import os from io import BytesIO from itertools import chain from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlsplit @@ -401,15 +402,18 @@ def body(self): ) # Limit the maximum request data size that will be handled - # in-memory. - if ( - settings.DATA_UPLOAD_MAX_MEMORY_SIZE is not None - and int(self.META.get("CONTENT_LENGTH") or 0) - > settings.DATA_UPLOAD_MAX_MEMORY_SIZE - ): - raise RequestDataTooBig( - "Request body exceeded settings.DATA_UPLOAD_MAX_MEMORY_SIZE." - ) + # in-memory. Reject early when Content-Length is present and + # already exceeds the limit, avoiding reading the body at all. + self._check_data_too_big(int(self.META.get("CONTENT_LENGTH") or 0)) + + # Content-Length can be absent or understated (e.g. + # `Transfer-Encoding: chunked` on ASGI), so for seekable + # streams (e.g. SpooledTemporaryFile on ASGI), check the actual + # buffered size before reading it all into memory. + if self._stream.seekable(): + stream_size = self._stream.seek(0, os.SEEK_END) + self._check_data_too_big(stream_size) + self._stream.seek(0) try: self._body = self.read() @@ -420,6 +424,14 @@ def body(self): self._stream = BytesIO(self._body) return self._body + def _check_data_too_big(self, length): + if ( + settings.DATA_UPLOAD_MAX_MEMORY_SIZE is not None + and length > settings.DATA_UPLOAD_MAX_MEMORY_SIZE + ): + msg = "Request body exceeded settings.DATA_UPLOAD_MAX_MEMORY_SIZE." + raise RequestDataTooBig(msg) + def _mark_post_parse_error(self): self._post = QueryDict() self._files = MultiValueDict() diff --git a/django/tasks/base.py b/django/tasks/base.py index bb37838d2f50..94eb29f2eac9 100644 --- a/django/tasks/base.py +++ b/django/tasks/base.py @@ -43,11 +43,11 @@ class TaskResultStatus(TextChoices): @dataclass(frozen=True, slots=True, kw_only=True) class Task: - priority: int func: Callable[..., Any] # The Task function. - backend: str - queue_name: str - run_after: datetime | None # The earliest this Task will run. + priority: int = DEFAULT_TASK_PRIORITY + backend: str = DEFAULT_TASK_BACKEND_ALIAS + queue_name: str = DEFAULT_TASK_QUEUE_NAME + run_after: datetime | None = None # The earliest this Task will run. # Whether the Task receives the Task context when executed. takes_context: bool = False @@ -138,17 +138,24 @@ def task( queue_name=DEFAULT_TASK_QUEUE_NAME, backend=DEFAULT_TASK_BACKEND_ALIAS, takes_context=False, + **kwargs, ): from . import task_backends + if "run_after" in kwargs: + raise TypeError( + "run_after cannot be defined statically with the @task decorator. " + "Use .using(run_after=...) to set it dynamically." + ) + def wrapper(f): return task_backends[backend].task_class( - priority=priority, func=f, + priority=priority, queue_name=queue_name, backend=backend, takes_context=takes_context, - run_after=None, + **kwargs, ) if function: diff --git a/django/test/client.py b/django/test/client.py index c7cdd24abf21..0f986d5a6c81 100644 --- a/django/test/client.py +++ b/django/test/client.py @@ -773,7 +773,10 @@ def generic( if headers: extra.update(HttpHeaders.to_asgi_names(headers)) s["headers"] += [ - (key.lower().encode("ascii"), value.encode("latin1")) + # Avoid breaking test clients that just want to supply normalized + # ASGI names, regardless of the fact that ASGIRequest drops headers + # with underscores (CVE-2026-3902). + (key.lower().replace("_", "-").encode("ascii"), value.encode("latin1")) for key, value in extra.items() ] return self.request(**s) diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 320ec7f83e44..cca7b18f24c9 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -1037,14 +1037,19 @@ The maximum size in bytes that a request body may be before a :exc:`~django.core.exceptions.SuspiciousOperation` (``RequestDataTooBig``) is raised. The check is done when accessing ``request.body`` or ``request.POST`` and is calculated against the total request size excluding any file upload -data. You can set this to ``None`` to disable the check. Applications that are -expected to receive unusually large form posts should tune this setting. - -The amount of request data is correlated to the amount of memory needed to -process the request and populate the GET and POST dictionaries. Large requests -could be used as a denial-of-service attack vector if left unchecked. Since web -servers don't typically perform deep request inspection, it's not possible to -perform a similar check at that level. +data (``request.FILES``). You can set this to ``None`` to disable the check. +Applications that are expected to receive unusually large form posts should +tune this setting. + +Under ASGI, the entire request may be spooled to disk before this limit is +enforced. Therefore, it is strongly recommended to place additional protections +in front of Django which limit the entire request payload. + +The amount of request data is correlated to the amount of memory or storage +needed to process the request and populate the GET and POST dictionaries. +Large requests could be used as a denial-of-service attack vector if left +unchecked. Since web servers don't typically perform deep request inspection, +it's not possible to perform a similar check at that level. See also :setting:`FILE_UPLOAD_MAX_MEMORY_SIZE`. diff --git a/docs/ref/tasks.txt b/docs/ref/tasks.txt index 99d8eb6a0aac..1850c91cab18 100644 --- a/docs/ref/tasks.txt +++ b/docs/ref/tasks.txt @@ -17,10 +17,13 @@ Task definition The ``task`` decorator ---------------------- -.. function:: task(*, priority=0, queue_name="default", backend="default", takes_context=False) +.. function:: task(*, priority=0, queue_name="default", backend="default", takes_context=False, **kwargs) - The ``@task`` decorator defines a :class:`Task` instance. This has the - following optional arguments: + The ``@task`` decorator defines a :class:`Task` instance. All keyword + arguments are passed directly to the backend's ``task_class`` (which + defaults to :class:`Task`). + + The following standard arguments are supported: * ``priority``: Sets the :attr:`~Task.priority` of the ``Task``. Defaults to 0. @@ -32,6 +35,18 @@ The ``task`` decorator :class:`TaskContext`. Defaults to ``False``. See :ref:`Task context ` for details. + Custom Task backends may define a custom ``task_class`` that accepts + additional arguments. These can be passed through the ``@task`` decorator:: + + @task(foo=5, bar=600) + def my_task(): + pass + + .. versionchanged:: 6.1 + + Support for passing arbitrary ``**kwargs`` to the ``@task`` decorator + is added. + If the defined ``Task`` is not valid according to the backend, :exc:`~django.tasks.exceptions.InvalidTask` is raised. @@ -75,6 +90,8 @@ The ``task`` decorator current time, a timezone-aware :class:`datetime `, or ``None`` if not constrained. Defaults to ``None``. + This attribute can be set using :meth:`~Task.using`. + The backend must have :attr:`.supports_defer` set to ``True`` to use this feature. Otherwise, :exc:`~django.tasks.exceptions.InvalidTask` is raised. @@ -291,6 +308,13 @@ Base backend ``BaseTaskBackend`` is the parent class for all Task backends. + .. attribute:: BaseTaskBackend.task_class + + The :class:`~django.tasks.Task` subclass to use when creating tasks + with the :func:`~django.tasks.task` decorator. Defaults to + :class:`~django.tasks.Task`. Custom backends can override this to use + a custom ``Task`` subclass with additional attributes. + .. attribute:: BaseTaskBackend.options A dictionary of extra parameters for the Task backend. These are diff --git a/docs/releases/4.2.30.txt b/docs/releases/4.2.30.txt index a2679c773684..8382907068f1 100644 --- a/docs/releases/4.2.30.txt +++ b/docs/releases/4.2.30.txt @@ -6,3 +6,64 @@ Django 4.2.30 release notes Django 4.2.30 fixes one security issue with severity "moderate" and four security issues with severity "low" in 4.2.29. + +CVE-2026-3902: ASGI header spoofing via underscore/hyphen conflation +==================================================================== + +``ASGIRequest`` normalizes header names following WSGI conventions, mapping +hyphens to underscores. As a result, even in configurations where reverse +proxies carefully strip security-sensitive headers named with hyphens, such a +header could be spoofed by supplying a header named with underscores. + +Under WSGI, it is the responsibility of the server or proxy to avoid ambiguous +mappings. (Django's :djadmin:`runserver` was patched in :cve:`2015-0219`.) But +under ASGI, there is not the same uniform expectation, even if many proxies +protect against this under default configuration (including ``nginx`` via +``underscores_in_headers off;``). + +Headers containing underscores are now ignored by ``ASGIRequest``, matching the +behavior of :pypi:`Daphne `, the reference server for ASGI. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-4277: Privilege abuse in ``GenericInlineModelAdmin`` +============================================================= + +Add permissions on inline model instances were not validated on submission of +forged ``POST`` data in +:class:`~django.contrib.contenttypes.admin.GenericInlineModelAdmin`. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-4292: Privilege abuse in ``ModelAdmin.list_editable`` +============================================================== + +Admin changelist forms using +:attr:`~django.contrib.admin.ModelAdmin.list_editable` incorrectly allowed new +instances to be created via forged ``POST`` data. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-33033: Potential denial-of-service vulnerability in ``MultiPartParser`` via base64-encoded file upload +=============================================================================================================== + +When using ``django.http.multipartparser.MultiPartParser``, multipart uploads +with ``Content-Transfer-Encoding: base64`` that include excessive whitespace +may trigger repeated memory copying, potentially degrading performance. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. + +CVE-2026-33034: Potential denial-of-service vulnerability in ASGI requests via memory upload limit bypass +========================================================================================================= + +ASGI requests with a missing or understated ``Content-Length`` header could +bypass the :setting:`DATA_UPLOAD_MAX_MEMORY_SIZE` limit when reading +``HttpRequest.body``, potentially loading an unbounded request body into +memory and causing service degradation. + +This issue has severity "low" according to the :ref:`Django security policy +`. diff --git a/docs/releases/5.2.13.txt b/docs/releases/5.2.13.txt index ff391eff0f93..9b7ce3155a0e 100644 --- a/docs/releases/5.2.13.txt +++ b/docs/releases/5.2.13.txt @@ -6,3 +6,64 @@ Django 5.2.13 release notes Django 5.2.13 fixes one security issue with severity "moderate" and four security issues with severity "low" in 5.2.12. + +CVE-2026-3902: ASGI header spoofing via underscore/hyphen conflation +==================================================================== + +``ASGIRequest`` normalizes header names following WSGI conventions, mapping +hyphens to underscores. As a result, even in configurations where reverse +proxies carefully strip security-sensitive headers named with hyphens, such a +header could be spoofed by supplying a header named with underscores. + +Under WSGI, it is the responsibility of the server or proxy to avoid ambiguous +mappings. (Django's :djadmin:`runserver` was patched in :cve:`2015-0219`.) But +under ASGI, there is not the same uniform expectation, even if many proxies +protect against this under default configuration (including ``nginx`` via +``underscores_in_headers off;``). + +Headers containing underscores are now ignored by ``ASGIRequest``, matching the +behavior of :pypi:`Daphne `, the reference server for ASGI. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-4277: Privilege abuse in ``GenericInlineModelAdmin`` +============================================================= + +Add permissions on inline model instances were not validated on submission of +forged ``POST`` data in +:class:`~django.contrib.contenttypes.admin.GenericInlineModelAdmin`. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-4292: Privilege abuse in ``ModelAdmin.list_editable`` +============================================================== + +Admin changelist forms using +:attr:`~django.contrib.admin.ModelAdmin.list_editable` incorrectly allowed new +instances to be created via forged ``POST`` data. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-33033: Potential denial-of-service vulnerability in ``MultiPartParser`` via base64-encoded file upload +=============================================================================================================== + +When using ``django.http.multipartparser.MultiPartParser``, multipart uploads +with ``Content-Transfer-Encoding: base64`` that include excessive whitespace +may trigger repeated memory copying, potentially degrading performance. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. + +CVE-2026-33034: Potential denial-of-service vulnerability in ASGI requests via memory upload limit bypass +========================================================================================================= + +ASGI requests with a missing or understated ``Content-Length`` header could +bypass the :setting:`DATA_UPLOAD_MAX_MEMORY_SIZE` limit when reading +``HttpRequest.body``, potentially loading an unbounded request body into +memory and causing service degradation. + +This issue has severity "low" according to the :ref:`Django security policy +`. diff --git a/docs/releases/6.0.4.txt b/docs/releases/6.0.4.txt index de75dc7d1332..f6a677d47b17 100644 --- a/docs/releases/6.0.4.txt +++ b/docs/releases/6.0.4.txt @@ -7,6 +7,67 @@ Django 6.0.4 release notes Django 6.0.4 fixes one security issue with severity "moderate", four security issues with severity "low", and several bugs in 6.0.3. +CVE-2026-3902: ASGI header spoofing via underscore/hyphen conflation +==================================================================== + +``ASGIRequest`` normalizes header names following WSGI conventions, mapping +hyphens to underscores. As a result, even in configurations where reverse +proxies carefully strip security-sensitive headers named with hyphens, such a +header could be spoofed by supplying a header named with underscores. + +Under WSGI, it is the responsibility of the server or proxy to avoid ambiguous +mappings. (Django's :djadmin:`runserver` was patched in :cve:`2015-0219`.) But +under ASGI, there is not the same uniform expectation, even if many proxies +protect against this under default configuration (including ``nginx`` via +``underscores_in_headers off;``). + +Headers containing underscores are now ignored by ``ASGIRequest``, matching the +behavior of :pypi:`Daphne `, the reference server for ASGI. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-4277: Privilege abuse in ``GenericInlineModelAdmin`` +============================================================= + +Add permissions on inline model instances were not validated on submission of +forged ``POST`` data in +:class:`~django.contrib.contenttypes.admin.GenericInlineModelAdmin`. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-4292: Privilege abuse in ``ModelAdmin.list_editable`` +============================================================== + +Admin changelist forms using +:attr:`~django.contrib.admin.ModelAdmin.list_editable` incorrectly allowed new +instances to be created via forged ``POST`` data. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2026-33033: Potential denial-of-service vulnerability in ``MultiPartParser`` via base64-encoded file upload +=============================================================================================================== + +When using ``django.http.multipartparser.MultiPartParser``, multipart uploads +with ``Content-Transfer-Encoding: base64`` that include excessive whitespace +may trigger repeated memory copying, potentially degrading performance. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. + +CVE-2026-33034: Potential denial-of-service vulnerability in ASGI requests via memory upload limit bypass +========================================================================================================= + +ASGI requests with a missing or understated ``Content-Length`` header could +bypass the :setting:`DATA_UPLOAD_MAX_MEMORY_SIZE` limit when reading +``HttpRequest.body``, potentially loading an unbounded request body into +memory and causing service degradation. + +This issue has severity "low" according to the :ref:`Django security policy +`. + Bugfixes ======== diff --git a/docs/releases/6.0.5.txt b/docs/releases/6.0.5.txt new file mode 100644 index 000000000000..dbe684e59b64 --- /dev/null +++ b/docs/releases/6.0.5.txt @@ -0,0 +1,12 @@ +========================== +Django 6.0.5 release notes +========================== + +*Expected May 5, 2026* + +Django 6.0.5 fixes several bugs in 6.0.4. + +Bugfixes +======== + +* ... diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 5dcdc9c50d80..9c8aeea70c3d 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -348,7 +348,9 @@ Signals Tasks ~~~~~ -* ... +* The :func:`~django.tasks.task` decorator now accepts ``**kwargs``, which are + forwarded to the backend's + :attr:`~django.tasks.backends.base.BaseTaskBackend.task_class`. Templates ~~~~~~~~~ diff --git a/docs/releases/index.txt b/docs/releases/index.txt index 6457825b1949..5a954cf254ca 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.5 6.0.4 6.0.3 6.0.2 diff --git a/docs/releases/security.txt b/docs/releases/security.txt index acab6487a7e9..b689d90f1de6 100644 --- a/docs/releases/security.txt +++ b/docs/releases/security.txt @@ -36,6 +36,63 @@ Issues under Django's security process All security issues have been handled under versions of Django's security process. These are listed below. +April 7, 2026 - :cve:`2026-3902` +-------------------------------- + +ASGI header spoofing via underscore/hyphen conflation. +`Full description +`__ + +* Django 6.0 :commit:`(patch) ` +* Django 5.2 :commit:`(patch) <1cc2a7612f97c109b92415fc11ba9bd0501852e0>` +* Django 4.2 :commit:`(patch) <4412731aa64d62a6dd7edae79e0c15b72666d7ca>` + +April 7, 2026 - :cve:`2026-4277` +-------------------------------- + +Privilege abuse in ``GenericInlineModelAdmin``. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <08a752c1cd8f378b4c64d96c319da23726df6ed3>` +* Django 5.2 :commit:`(patch) <60ffa957c427e10a2eb0fc80d1674a8a8ccc30b0>` +* Django 4.2 :commit:`(patch) <051f3909e820360bbe84a21350e82f4961e3d917>` + +April 7, 2026 - :cve:`2026-4292` +-------------------------------- + +Privilege abuse in ``ModelAdmin.list_editable``. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <428c48f358c5a0ed5ca2834fb721d615eb2b0e11>` +* Django 5.2 :commit:`(patch) <397c22048244db2cd4bb78f570e6c72a3967bf36>` +* Django 4.2 :commit:`(patch) ` + +April 7, 2026 - :cve:`2026-33033` +--------------------------------- + +Potential denial-of-service vulnerability in ``MultiPartParser`` via +base64-encoded file upload. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <0910af60468216c856dfbcac1177372c225deb76>` +* Django 5.2 :commit:`(patch) <0b467893bdde69a2d23034338e76021a1e4f4322>` +* Django 4.2 :commit:`(patch) ` + +April 7, 2026 - :cve:`2026-33034` +--------------------------------- + +Potential denial-of-service vulnerability in ASGI requests via memory upload +limit bypass. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <393dbc53e848876fdba92fbf02e10ee6a6eace6b>` +* Django 5.2 :commit:`(patch) <49e1e2b548999a35a025f9682598946bda9e9921>` +* Django 4.2 :commit:`(patch) ` + March 3, 2026 - :cve:`2026-25673` --------------------------------- diff --git a/docs/topics/security.txt b/docs/topics/security.txt index 2e828db0ab4d..ea3021c26d6c 100644 --- a/docs/topics/security.txt +++ b/docs/topics/security.txt @@ -253,7 +253,9 @@ User-uploaded content * If your site accepts file uploads, it is strongly advised that you limit these uploads in your web server configuration to a reasonable size in order to prevent denial of service (DOS) attacks. In Apache, this - can be easily set using the LimitRequestBody_ directive. + can be easily set using the LimitRequestBody_ directive. You should not rely + solely on :setting:`DATA_UPLOAD_MAX_MEMORY_SIZE` + nor :setting:`FILE_UPLOAD_MAX_MEMORY_SIZE`. * If you are serving your own static files, be sure that handlers like Apache's ``mod_php``, which would execute static files as code, are disabled. You @@ -287,6 +289,15 @@ User-uploaded content .. _same-origin policy: https://en.wikipedia.org/wiki/Same-origin_policy +Form Submissions +================ + +* Form submissions containing files are not limited by + :setting:`DATA_UPLOAD_MAX_MEMORY_SIZE`. Under ASGI, the entire request may be + spooled to disk before any file size validation is performed. It is strongly + advised that you limit the maximum request body size in your web server + configuration to prevent denial of service (DOS) attacks. + .. _security-csp: Content Security Policy diff --git a/scripts/verify_release.sh b/scripts/verify_release.sh index d808b62cfa30..03568f14d1ac 100755 --- a/scripts/verify_release.sh +++ b/scripts/verify_release.sh @@ -67,15 +67,15 @@ sha1sum --check "${CHECKSUM_FILE}" 2>/dev/null echo "- SHA256 checksums" sha256sum --check "${CHECKSUM_FILE}" 2>/dev/null -PKG_TAR=$(ls Django-*.tar.gz) -PKG_WHL=$(ls Django-*.whl) +PKG_TAR=$(ls django-*.tar.gz) +PKG_WHL=$(ls django-*.whl) echo "Testing tarball install ..." python3 -m venv django-pip . django-pip/bin/activate python -m pip install --no-cache-dir "${WORKDIR}/${PKG_TAR}" -django-admin startproject test_one -cd test_one +django-admin startproject test_tarball +cd test_tarball ./manage.py --help # Ensure executable bits python manage.py migrate python manage.py runserver 0 @@ -86,8 +86,8 @@ echo "Testing wheel install ..." python3 -m venv django-pip-wheel . django-pip-wheel/bin/activate python -m pip install --no-cache-dir "${WORKDIR}/${PKG_WHL}" -django-admin startproject test_one -cd test_one +django-admin startproject test_wheel +cd test_wheel ./manage.py --help # Ensure executable bits python manage.py migrate python manage.py runserver 0 diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py index 0f05a6674661..26648a1e47cc 100644 --- a/tests/admin_views/admin.py +++ b/tests/admin_views/admin.py @@ -369,6 +369,14 @@ def get_queryset(self, request): return super().get_queryset(request).order_by("age") +class ParentWithUUIDPKAdmin(admin.ModelAdmin): + list_display = ("id", "title") + list_editable = ("title",) + + def has_add_permission(self, request): + return False + + class FooAccountAdmin(admin.StackedInline): model = FooAccount extra = 1 @@ -1286,7 +1294,7 @@ class CourseAdmin(admin.ModelAdmin): site.register(InlineReferer, InlineRefererAdmin) site.register(ReferencedByGenRel) site.register(GenRelReference) -site.register(ParentWithUUIDPK) +site.register(ParentWithUUIDPK, ParentWithUUIDPKAdmin) site.register(RelatedPrepopulated, search_fields=["name"]) site.register(RelatedWithUUIDPKModel) site.register(ReadOnlyRelatedField, ReadOnlyRelatedFieldAdmin) diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index 4359a3113501..107267b3422f 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -4,6 +4,7 @@ import sys import unittest import zoneinfo +from http import HTTPStatus from unittest import mock from urllib.parse import parse_qsl, urljoin, urlsplit @@ -4730,6 +4731,22 @@ def test_post_submission(self): self.assertIs(Person.objects.get(name="John Mauchly").alive, False) + def test_forged_post_submission_when_no_add_permission(self): + before_count = ParentWithUUIDPK.objects.count() + data = { + "form-TOTAL_FORMS": "1", + "form-INITIAL_FORMS": "0", + "form-MAX_NUM_FORMS": "0", + "form-0-title": "The News", + "form-0-id": "", + "_save": "Save", + } + # This model admin allows no add permissions. + changelist_url = reverse("admin:admin_views_parentwithuuidpk_changelist") + response = self.client.post(changelist_url, data) + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + self.assertEqual(ParentWithUUIDPK.objects.count(), before_count) + def test_non_field_errors(self): """ Non-field errors are displayed for each of the forms in the diff --git a/tests/asgi/tests.py b/tests/asgi/tests.py index 6a44d21d386e..f77bd997a4aa 100644 --- a/tests/asgi/tests.py +++ b/tests/asgi/tests.py @@ -3,6 +3,7 @@ import tempfile import threading import time +from io import BytesIO from pathlib import Path from unittest.mock import patch @@ -31,6 +32,7 @@ from .urls import sync_waiter, test_filename TEST_STATIC_ROOT = Path(__file__).parent / "project" / "static" +TOO_MUCH_DATA_MSG = "Request body exceeded settings.DATA_UPLOAD_MAX_MEMORY_SIZE." class SignalHandler: @@ -280,6 +282,17 @@ def META(self, value): self.assertEqual(len(request.headers["foo"].split(",")), 200_000) self.assertLessEqual(setitem_count, 100) + async def test_underscores_in_headers_ignored(self): + scope = self.async_request_factory._base_scope(path="/", http_version="2.0") + scope["headers"] = [(b"some_header", b"1")] + request = ASGIRequest(scope, None) + # No form of the header exists anywhere. + self.assertNotIn("Some_Header", request.headers) + self.assertNotIn("Some-Header", request.headers) + self.assertNotIn("SOME_HEADER", request.META) + self.assertNotIn("SOME-HEADER", request.META) + self.assertNotIn("HTTP_SOME_HEADER", request.META) + async def test_cancel_post_request_with_sync_processing(self): """ The request.body object should be available and readable in view @@ -789,3 +802,166 @@ def test_multiple_cookie_headers_http2(self): request = ASGIRequest(scope, None) self.assertEqual(request.META["HTTP_COOKIE"], "a=abc; b=def; c=ghi") self.assertEqual(request.COOKIES, {"a": "abc", "b": "def", "c": "ghi"}) + + +class DataUploadMaxMemorySizeASGITests(SimpleTestCase): + + def make_request( + self, + body, + content_type=b"application/octet-stream", + content_length=None, + stream=None, + ): + scope = AsyncRequestFactory()._base_scope(method="POST", path="/") + scope["headers"] = [(b"content-type", content_type)] + if content_length is not None: + scope["headers"].append((b"content-length", str(content_length).encode())) + return ASGIRequest(scope, stream if stream is not None else BytesIO(body)) + + def test_body_size_not_exceeded_without_content_length(self): + body = b"x" * 5 + with self.settings(DATA_UPLOAD_MAX_MEMORY_SIZE=5): + self.assertEqual(self.make_request(body).body, body) + + def test_body_size_exceeded_without_content_length(self): + request = self.make_request(b"x" * 10) + with ( + self.settings(DATA_UPLOAD_MAX_MEMORY_SIZE=5), + self.assertRaisesMessage(RequestDataTooBig, TOO_MUCH_DATA_MSG), + ): + request.body + + def test_body_size_check_fires_before_read(self): + # The seekable size check rejects oversized bodies before reading + # them into memory (i.e. before calling self.read()). + class TrackingBytesIO(BytesIO): + calls = [] + + def read(self, *args, **kwargs): + self.calls.append((args, kwargs)) + return super().read(*args, **kwargs) + + stream = TrackingBytesIO(b"x" * 10) + request = self.make_request(b"x" * 10, stream=stream) + with ( + self.settings(DATA_UPLOAD_MAX_MEMORY_SIZE=5), + self.assertRaisesMessage(RequestDataTooBig, TOO_MUCH_DATA_MSG), + ): + request.body + + self.assertEqual(stream.calls, []) + + def test_post_size_exceeded_without_content_length(self): + request = self.make_request( + b"a=" + b"x" * 10, + content_type=b"application/x-www-form-urlencoded", + ) + with ( + self.settings(DATA_UPLOAD_MAX_MEMORY_SIZE=5), + self.assertRaisesMessage(RequestDataTooBig, TOO_MUCH_DATA_MSG), + ): + request.POST + + def test_no_limit(self): + body = b"x" * 100 + with self.settings(DATA_UPLOAD_MAX_MEMORY_SIZE=None): + self.assertEqual(self.make_request(body).body, body) + + async def test_read_body_no_limit(self): + chunks = [ + {"type": "http.request", "body": b"x" * 100, "more_body": True}, + {"type": "http.request", "body": b"x" * 100, "more_body": False}, + ] + + async def receive(): + return chunks.pop(0) + + handler = ASGIHandler() + with self.settings(DATA_UPLOAD_MAX_MEMORY_SIZE=None): + body_file = await handler.read_body(receive) + self.addCleanup(body_file.close) + + body_file.seek(0) + self.assertEqual(body_file.read(), b"x" * 200) + + def test_non_multipart_body_size_enforced(self): + # DATA_UPLOAD_MAX_MEMORY_SIZE is enforced on non-multipart bodies. + request = self.make_request(b"x" * 100) + with ( + self.settings(DATA_UPLOAD_MAX_MEMORY_SIZE=10), + self.assertRaisesMessage(RequestDataTooBig, TOO_MUCH_DATA_MSG), + ): + request.body + + def test_multipart_file_upload_not_limited_by_data_upload_max(self): + # DATA_UPLOAD_MAX_MEMORY_SIZE applies to non-file fields only; a file + # upload whose total body exceeds the limit must still succeed. + 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() + ) + request = self.make_request( + body, + content_type=f"multipart/form-data; boundary={boundary}".encode(), + content_length=len(body), + ) + with self.settings( + DATA_UPLOAD_MAX_MEMORY_SIZE=10, FILE_UPLOAD_MAX_MEMORY_SIZE=10 + ): + files = request.FILES + self.assertEqual(len(files), 1) + uploaded = files["file"] + 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 + # HttpRequest.body is accessed. + chunks = [ + {"type": "http.request", "body": b"x" * 10, "more_body": True}, + {"type": "http.request", "body": b"y" * 10, "more_body": True}, + {"type": "http.request", "body": b"z" * 10, "more_body": False}, + ] + + async def receive(): + return chunks.pop(0) + + handler = ASGIHandler() + with self.settings(DATA_UPLOAD_MAX_MEMORY_SIZE=15): + body_file = await handler.read_body(receive) + self.addCleanup(body_file.close) + + self.assertEqual(len(chunks), 0) # All chunks were consumed. + body_file.seek(0) + self.assertEqual(body_file.read(), b"x" * 10 + b"y" * 10 + b"z" * 10) + + async def test_read_body_multipart_not_limited(self): + # All chunks are consumed regardless of DATA_UPLOAD_MAX_MEMORY_SIZE; + # multipart size enforcement happens inside MultiPartParser, not here. + chunks = [ + {"type": "http.request", "body": b"x" * 10, "more_body": True}, + {"type": "http.request", "body": b"y" * 10, "more_body": True}, + {"type": "http.request", "body": b"z" * 10, "more_body": False}, + ] + + async def receive(): + return chunks.pop(0) + + handler = ASGIHandler() + with self.settings(DATA_UPLOAD_MAX_MEMORY_SIZE=15): + body_file = await handler.read_body(receive) + self.addCleanup(body_file.close) + + self.assertEqual(len(chunks), 0) # All chunks were consumed. + body_file.seek(0) + self.assertEqual(body_file.read(), b"x" * 10 + b"y" * 10 + b"z" * 10) diff --git a/tests/generic_inline_admin/tests.py b/tests/generic_inline_admin/tests.py index dfc8f4f7cf18..b4833e54e797 100644 --- a/tests/generic_inline_admin/tests.py +++ b/tests/generic_inline_admin/tests.py @@ -1,6 +1,6 @@ from django.contrib import admin from django.contrib.admin.sites import AdminSite -from django.contrib.auth.models import User +from django.contrib.auth.models import Permission, User from django.contrib.contenttypes.admin import GenericTabularInline from django.contrib.contenttypes.models import ContentType from django.forms.formsets import DEFAULT_MAX_NUM @@ -8,9 +8,9 @@ from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings from django.urls import reverse -from .admin import MediaInline, MediaPermanentInline +from .admin import MediaInline, MediaPermanentInline, PhoneNumberInline from .admin import site as admin_site -from .models import Category, Episode, EpisodePermanent, Media, PhoneNumber +from .models import Category, Contact, Episode, EpisodePermanent, Media, PhoneNumber class TestDataMixin: @@ -295,12 +295,102 @@ def test_delete(self): @override_settings(ROOT_URLCONF="generic_inline_admin.urls") -class NoInlineDeletionTest(SimpleTestCase): - def test_no_deletion(self): - inline = MediaPermanentInline(EpisodePermanent, admin_site) - fake_request = object() - formset = inline.get_formset(fake_request) - self.assertFalse(formset.can_delete) +class GenericInlineAdminPermissionsTest(TestCase): + factory = RequestFactory() + + @classmethod + def setUpTestData(cls): + cls.user = User(username="admin", is_staff=True, is_active=True) + cls.user.set_password("secret") + cls.user.save() + + # User always has all permissions on Contact (parent) model. + # Permissions on the inlines vary per test. + cls.contact_type = ContentType.objects.get_for_model(Contact) + cls.user.user_permissions.add( + *Permission.objects.filter(content_type=cls.contact_type) + ) + + def test_add_inline_without_add_permission(self): + self.client.force_login(self.user) + inline_view_perm = Permission.objects.get(codename="view_phonenumber") + self.user.user_permissions.add(inline_view_perm) + + category_id = Category.objects.create(name="test").pk + prefix = "generic_inline_admin-phonenumber-content_type-object_id" + post_data = { + "name": "Barbara", + # inline data + f"{prefix}-TOTAL_FORMS": "1", + f"{prefix}-INITIAL_FORMS": "0", + f"{prefix}-MIN_NUM_FORMS": "0", + f"{prefix}-MAX_NUM_FORMS": "0", + f"{prefix}-0-id": "", + f"{prefix}-0-phone_number": "555-555-5555", + f"{prefix}-0-category": str(category_id), + } + request = self.factory.get(reverse("admin:generic_inline_admin_contact_add")) + request.user = self.user + inline = PhoneNumberInline(Contact, AdminSite()) + FormSet = inline.get_formset(request) + formset = FormSet( + data=post_data, prefix=prefix, instance=Contact(name="Barbara") + ) + + self.assertIs(formset.is_valid(), True) + self.assertIs(formset.has_changed(), False) + self.assertEqual(formset.save(commit=False), []) + + def test_add_inline_with_change_permission_only(self): + """ + Forged new inline instances are ignored without add permissions, but + but edits still work with edit permissions. + """ + self.client.force_login(self.user) + inline_perms = Permission.objects.filter( + codename__in=("view_phonenumber", "change_phonenumber") + ) + self.user.user_permissions.add(*inline_perms) + + category_id = Category.objects.create(name="test").pk + contact = Contact.objects.create(name="Barbara") + existing_number = PhoneNumber.objects.create( + category_id=category_id, + content_type=self.contact_type, + object_id=contact.pk, + phone_number="555-555-5555", + ) + prefix = "generic_inline_admin-phonenumber-content_type-object_id" + post_data = { + "id": str(contact.pk), + "name": "Barbara", + # inline data + f"{prefix}-TOTAL_FORMS": "2", + f"{prefix}-INITIAL_FORMS": "1", + f"{prefix}-MIN_NUM_FORMS": "0", + f"{prefix}-MAX_NUM_FORMS": "0", + # Attempt to edit the existing phone number value. + f"{prefix}-0-id": str(existing_number.id), + f"{prefix}-0-phone_number": "111-111-1111", + f"{prefix}-0-category": str(category_id), + # Attempt to forge a new phone number. + f"{prefix}-1-id": "", + f"{prefix}-1-phone_number": "666-666-6666", + f"{prefix}-1-category": str(category_id), + "_save": "Save", + } + request = self.factory.get( + reverse("admin:generic_inline_admin_contact_change", args=[contact.pk]) + ) + request.user = self.user + inline = PhoneNumberInline(Contact, AdminSite()) + FormSet = inline.get_formset(request) + formset = FormSet(data=post_data, prefix=prefix, instance=contact) + + self.assertIs(formset.is_valid(), True) + self.assertIs(formset.has_changed(), True) + # The edit succeeds; the add is ignored. + self.assertEqual(formset.save(commit=False), [existing_number]) class MockRequest: @@ -316,6 +406,14 @@ def has_perm(self, perm, obj=None): request.user = MockSuperUser() +@override_settings(ROOT_URLCONF="generic_inline_admin.urls") +class NoInlineDeletionTest(SimpleTestCase): + def test_no_deletion(self): + inline = MediaPermanentInline(EpisodePermanent, admin_site) + formset = inline.get_formset(request) + self.assertFalse(formset.can_delete) + + @override_settings(ROOT_URLCONF="generic_inline_admin.urls") class GenericInlineModelAdminTest(SimpleTestCase): def setUp(self): diff --git a/tests/requests_tests/tests.py b/tests/requests_tests/tests.py index e1744bf18059..b15d954cb39c 100644 --- a/tests/requests_tests/tests.py +++ b/tests/requests_tests/tests.py @@ -1,6 +1,7 @@ import copy from io import BytesIO from itertools import chain +from unittest import mock from urllib.parse import urlencode from django.core.exceptions import BadRequest, DisallowedHost @@ -15,6 +16,7 @@ ) from django.http.multipartparser import ( MAX_TOTAL_HEADER_SIZE, + LazyStream, MultiPartParser, MultiPartParserError, ) @@ -917,6 +919,65 @@ def test_multipart_post_field_with_invalid_base64(self): request.body # evaluate self.assertEqual(request.POST, {"name": ["123"]}) + def test_multipart_file_upload_base64_whitespace_heavy(self): + # Fake a file upload with base64-encoded content including mostly + # whitespaces across chunk boundaries. + payload = FakePayload( + "\r\n".join( + [ + f"--{BOUNDARY}", + 'Content-Disposition: form-data; name="file"; filename="test.txt"', + "Content-Type: application/octet-stream", + "Content-Transfer-Encoding: base64", + "", + ] + ) + ) + # "AAAA" decodes to b"\x00\x00\x00". Whitespace (70000 bytes) spans the + # default 64KB chunk boundary, hence the alignment loop is exercised. + payload.write(b"\r\n" + b"AAA" + b" " * 70000 + b"A" + b"\r\n") + payload.write("--" + BOUNDARY + "--\r\n") + request = WSGIRequest( + { + "REQUEST_METHOD": "POST", + "CONTENT_TYPE": MULTIPART_CONTENT, + "CONTENT_LENGTH": len(payload), + "wsgi.input": payload, + } + ) + reads = [] + original_read = LazyStream.read + + def counting_read(self_stream, size=None): + reads.append(size) + return original_read(self_stream, size) + + with mock.patch.object(LazyStream, "read", counting_read): + files = request.FILES + + self.assertEqual(len(files), 1) + self.assertEqual(files["file"].read(), b"\x00\x00\x00") + + # The alignment loop must read in `chunk-sized` units rather than one + # byte at a time, otherwise each whitespace byte triggers a separate + # read() call with a costly internal unget() cycle. + # Parsing this payload should issue exactly 8 LazyStream.read() calls: + # 1. main_stream.read(1) -- BoundaryIter.__init__ probe, preamble + # 2. sub_stream.read(1024) -- parse_boundary_stream, preamble headers + # 3. main_stream.read(1) -- BoundaryIter.__init__ probe, file field + # 4. field_stream.read(1024) -- parse_boundary_stream, file headers + # 5. field_stream.read(65536)-- base64 alignment loop: one chunk-sized + # read to find the non-whitespace bytes + # needed to complete the 4-byte base64 + # group that spans the chunk boundary + # 6. main_stream.read(1) -- BoundaryIter.__init__ probe, epilogue + # 7. sub_stream.read(1024) -- parse_boundary_stream, epilogue headers + # 8. main_stream.read(1) -- BoundaryIter.__init__ probe, exhausted + # stream; returns b"" and stops iteration + # A byte-at-a-time implementation of read() in step 5 would do instead + # one read(1) per whitespace byte past the chunk boundary (4488 calls). + self.assertEqual(reads, [1, 1024, 1, 1024, 65536, 1, 1024, 1]) + def test_POST_after_body_read_and_stream_read_multipart(self): """ POST should be populated even if body is read first, and then diff --git a/tests/tasks/test_custom_backend.py b/tests/tasks/test_custom_backend.py index c8508d5d3b23..f3851622edfb 100644 --- a/tests/tasks/test_custom_backend.py +++ b/tests/tasks/test_custom_backend.py @@ -1,7 +1,8 @@ import logging +from dataclasses import dataclass from unittest import mock -from django.tasks import default_task_backend, task_backends +from django.tasks import Task, default_task_backend, task, task_backends from django.tasks.backends.base import BaseTaskBackend from django.tasks.exceptions import InvalidTask from django.test import SimpleTestCase, override_settings @@ -23,6 +24,20 @@ class CustomBackendNoEnqueue(BaseTaskBackend): pass +@dataclass(frozen=True, slots=True, kw_only=True) +class CustomTask(Task): + foo: int = 3 + bar: int = 300 + + +class CustomTaskBackend(BaseTaskBackend): + task_class = CustomTask + supports_priority = True + + def enqueue(self, task, args, kwargs): + pass + + @override_settings( TASKS={ "default": { @@ -68,3 +83,45 @@ def test_no_enqueue(self): "without an implementation for abstract method 'enqueue'", ): test_tasks.noop_task.using(backend="no_enqueue") + + +@override_settings( + TASKS={ + "default": { + "BACKEND": f"{CustomTaskBackend.__module__}." + f"{CustomTaskBackend.__qualname__}", + "QUEUES": ["default", "high"], + }, + } +) +class CustomTaskTestCase(SimpleTestCase): + def test_custom_task_default_values(self): + my_task = task()(test_tasks.noop_task.func) + + self.assertIsInstance(my_task, CustomTask) + self.assertEqual(my_task.foo, 3) + self.assertEqual(my_task.bar, 300) + + def test_custom_task_with_custom_values(self): + my_task = task(foo=5, bar=600)(test_tasks.noop_task.func) + + self.assertIsInstance(my_task, CustomTask) + self.assertEqual(my_task.foo, 5) + self.assertEqual(my_task.bar, 600) + + def test_custom_task_with_standard_and_custom_values(self): + my_task = task(priority=10, queue_name="high", foo=10, bar=1000)( + test_tasks.noop_task.func + ) + + self.assertIsInstance(my_task, CustomTask) + self.assertEqual(my_task.priority, 10) + self.assertEqual(my_task.queue_name, "high") + self.assertEqual(my_task.foo, 10) + self.assertEqual(my_task.bar, 1000) + self.assertFalse(my_task.takes_context) + self.assertIsNone(my_task.run_after) + + def test_custom_task_invalid_kwarg(self): + with self.assertRaises(TypeError): + task(unknown_param=123)(test_tasks.noop_task.func) diff --git a/tests/tasks/test_tasks.py b/tests/tasks/test_tasks.py index 14d47c4cf625..b66c2df05813 100644 --- a/tests/tasks/test_tasks.py +++ b/tests/tasks/test_tasks.py @@ -312,3 +312,8 @@ def test_takes_context_without_taking_context(self): "Task takes context but does not have a first argument of 'context'.", ): task(takes_context=True)(test_tasks.calculate_meaning_of_life.func) + + def test_run_after_in_decorator(self): + msg = "run_after cannot be defined statically with the @task decorator." + with self.assertRaisesMessage(TypeError, msg): + task(run_after=timezone.now())(test_tasks.calculate_meaning_of_life.func)