From 142b881cecaddc334cabec139e701c0e4b9798da Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Tue, 9 Jun 2026 08:46:36 -0400 Subject: [PATCH 1/4] Refs #36560, CVE-2026-35193 -- Replaced substring check on cache-control directives in UpdateCacheMiddleware. Avoid false positives from hypothetical extension directives that could be superstrings of the ones we are checking. --- django/middleware/cache.py | 6 ++++-- tests/cache/tests.py | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/django/middleware/cache.py b/django/middleware/cache.py index 8ac1178b12b0..60c219064a1f 100644 --- a/django/middleware/cache.py +++ b/django/middleware/cache.py @@ -56,6 +56,7 @@ learn_cache_key, patch_response_headers, patch_vary_headers, + split_header_value, ) from django.utils.deprecation import MiddlewareMixin from django.utils.http import parse_http_date_safe @@ -106,8 +107,9 @@ def process_response(self, request, response): # Don't cache responses when the Cache-Control header is set to # private, no-cache, or no-store. cache_control = response.get("Cache-Control", "").lower() + cache_control_parts = list(split_header_value(cache_control)) if cache_control and any( - directive in cache_control + directive in cache_control_parts for directive in ( "private", "no-cache", @@ -137,7 +139,7 @@ def process_response(self, request, response): # header, unless allowed by "public" per RFC 9111, Section 3.5. No # exceptions are made for "s-maxage" and "must-revalidate" since these # are not currently implemented by Django. - if request.headers.get("Authorization") and "public" not in cache_control: + if request.headers.get("Authorization") and "public" not in cache_control_parts: patch_vary_headers(response, ("Authorization",)) if timeout and response.status_code == 200: cache_key = learn_cache_key( diff --git a/tests/cache/tests.py b/tests/cache/tests.py index 92641240b193..4bc3e1e9cee9 100644 --- a/tests/cache/tests.py +++ b/tests/cache/tests.py @@ -3026,6 +3026,23 @@ def view(request, value): response = view(request, "2") self.assertEqual(response.content, b"Hello World 2") + def test_cache_control_not_cached_superstring(self): + """ + "myprivate", a hypothetical extension directive, is not confused for + "private". + """ + + @cache_page(3) + @cache_control(myprivate=True) + def view(request, value): + return HttpResponse(f"Hello World {value}") + + 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 1") + def test_vary_asterisk_not_cached(self): views_with_cache = ( cache_page(3)(hello_world_view_patch_vary_headers_asterisk), @@ -3064,6 +3081,16 @@ def test_authorization_header_exceptions(self): response = view_with_cache(request, "1") self.assertIs(has_vary_header(response, "Authorization"), False) + def test_authorization_header_exception_superstring(self): + """ + "nopublic", a hypothetical extension directive, is not confused for + "public". + """ + view_with_cache = cache_page(3)(cache_control(no_public=True)(hello_world_view)) + request = self.factory.get("/view/", headers={"Authorization": "token"}) + response = view_with_cache(request, "1") + self.assertIs(has_vary_header(response, "Authorization"), True) + def test_sensitive_cookie_not_cached(self): """ Django must prevent caching of responses that set a user-specific (and From beb40ed1d2b0503814591a37b08ecbd71f2ff729 Mon Sep 17 00:00:00 2001 From: kikobarr <34662575+kikobarr@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:54:28 -0700 Subject: [PATCH 2/4] Fixed #37106 -- Clarified pylibmc workaround in unit test docs. --- docs/internals/contributing/writing-code/unit-tests.txt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/internals/contributing/writing-code/unit-tests.txt b/docs/internals/contributing/writing-code/unit-tests.txt index b6bfd5875da4..11de731d712e 100644 --- a/docs/internals/contributing/writing-code/unit-tests.txt +++ b/docs/internals/contributing/writing-code/unit-tests.txt @@ -39,10 +39,12 @@ that your computer doesn't have installed. You can usually figure out which package to install by doing a web search for the last line or so of the error message. Try adding your operating system to the search query if needed. -If you have trouble installing the requirements, you can skip that step. See +If you have trouble installing an optional test dependency, you can skip that +dependency locally. For example, if installing ``pylibmc`` fails, comment out +its line in ``requirements/py3.txt`` and continue with ``./runtests.py``. +Tests that require ``pylibmc`` will be skipped automatically. See :ref:`running-unit-tests-dependencies` for details on installing the optional -test dependencies. If you don't have an optional dependency installed, the -tests that require it will be skipped. +test dependencies. Running the tests requires a Django settings module that defines the databases to use. To help you get started, Django provides and uses a sample settings From 46c5e76f0bcc76bfce19ad7ba07f716fc653a822 Mon Sep 17 00:00:00 2001 From: ar3ph <192461522+ar3ph@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:20:49 +0000 Subject: [PATCH 3/4] Fixed #36900 -- Used safe_join() on downloaded template archive. --- django/core/management/templates.py | 3 ++- tests/admin_scripts/tests.py | 15 +++++++++++++++ tests/admin_scripts/urls.py | 6 ++++++ tests/admin_scripts/views.py | 17 +++++++++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/admin_scripts/views.py diff --git a/django/core/management/templates.py b/django/core/management/templates.py index ea2c4a294f65..1751f12cbcfc 100644 --- a/django/core/management/templates.py +++ b/django/core/management/templates.py @@ -18,6 +18,7 @@ ) from django.template import Context, Engine from django.utils import archive +from django.utils._os import safe_join from django.utils.http import parse_header_parameters from django.utils.version import get_docs_version @@ -345,7 +346,7 @@ def cleanup_url(url): # Move the temporary file to a filename that has better # chances of being recognized by the archive utils if used_name != guessed_filename: - guessed_path = os.path.join(tempdir, guessed_filename) + guessed_path = safe_join(tempdir, guessed_filename) shutil.move(the_path, guessed_path) return guessed_path diff --git a/tests/admin_scripts/tests.py b/tests/admin_scripts/tests.py index 819ba931d622..3eb7b97c99ce 100644 --- a/tests/admin_scripts/tests.py +++ b/tests/admin_scripts/tests.py @@ -2778,6 +2778,21 @@ def test_custom_project_template_from_tarball_by_url(self): self.assertTrue(os.path.isdir(testproject_dir)) self.assertTrue(os.path.exists(os.path.join(testproject_dir, "run.py"))) + def test_custom_project_template_from_tarball_by_url_bad_filename(self): + """ + The startproject management command will raise SuspiciousFileOperation + on an ill-formed remote template archive filename. + """ + template_url = "%s/bad_template_filename.tgz" % self.live_server_url + + args = ["startproject", "--template", template_url, "urltestproject"] + + out, err = self.run_django_admin(args) + self.assertOutput( + err, + "is located outside of the base path component", + ) + def test_custom_project_template_from_tarball_by_url_django_user_agent(self): user_agent = None diff --git a/tests/admin_scripts/urls.py b/tests/admin_scripts/urls.py index a6cc7fe1b5bf..3e7aa063d381 100644 --- a/tests/admin_scripts/urls.py +++ b/tests/admin_scripts/urls.py @@ -3,6 +3,8 @@ from django.urls import path from django.views.static import serve +from . import views + here = os.path.dirname(__file__) urlpatterns = [ @@ -11,4 +13,8 @@ serve, {"document_root": os.path.join(here, "custom_templates")}, ), + path( + "bad_template_filename.tgz", + views.template_bad_filename, + ), ] diff --git a/tests/admin_scripts/views.py b/tests/admin_scripts/views.py new file mode 100644 index 000000000000..d57803659d07 --- /dev/null +++ b/tests/admin_scripts/views.py @@ -0,0 +1,17 @@ +from pathlib import Path + +from django.http import FileResponse + + +def template_bad_filename(request): + content = Path(__file__).parent / "custom_templates" / "project_template.tgz" + f = open(content, "rb") + filename = "/nonexistent/archive.tgz" + response = FileResponse( + f, + as_attachment=True, + filename=filename, + ) + # Force the filename to have a slash at the beginning. + response["Content-Disposition"] = f'attachment; filename="{filename}"' + return response From bfb8679bd95e9387b848ef856e8eb6f1a4a4bac5 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Tue, 2 Jun 2026 15:22:22 -0400 Subject: [PATCH 4/4] Made check-commit-suffix job check only relevant commits. Failing to set $BASE meant other commits on the target branch were checked. --- .github/workflows/check_commit_messages.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check_commit_messages.yml b/.github/workflows/check_commit_messages.yml index 3aef211f8d06..0dbb36a9990e 100644 --- a/.github/workflows/check_commit_messages.yml +++ b/.github/workflows/check_commit_messages.yml @@ -22,7 +22,7 @@ jobs: with: persist-credentials: false - - name: Calculate commit prefix + - name: Set base and calculate commit prefix id: vars env: BASE: ${{ github.event.pull_request.base.ref }} @@ -80,6 +80,11 @@ jobs: with: persist-credentials: false + - name: Set base + env: + BASE: ${{ github.event.pull_request.base.ref }} + run: echo "BASE=$BASE" >> $GITHUB_ENV + - name: Fetch relevant branches run: | git fetch origin $BASE:base --depth=1