From 550822bceea227b07445d1852c4376b663c09ea4 Mon Sep 17 00:00:00 2001 From: Rob Hudson Date: Sat, 23 Aug 2025 12:23:53 -0700 Subject: Fixed #36532 -- Added Content Security Policy view decorators to override or disable policies. Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> --- tests/decorators/test_csp.py | 95 ++++++++++++++++++++++++++++++++++++++++++++ tests/middleware/test_csp.py | 69 ++++++++++++++++++++++++++++++++ tests/middleware/urls.py | 6 +++ tests/middleware/views.py | 40 +++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 tests/decorators/test_csp.py (limited to 'tests') diff --git a/tests/decorators/test_csp.py b/tests/decorators/test_csp.py new file mode 100644 index 0000000000..2def6b0f79 --- /dev/null +++ b/tests/decorators/test_csp.py @@ -0,0 +1,95 @@ +from itertools import product + +from asgiref.sync import iscoroutinefunction + +from django.http import HttpRequest, HttpResponse +from django.test import SimpleTestCase +from django.utils.csp import CSP +from django.views.decorators.csp import csp_override, csp_report_only_override + +basic_config = { + "default-src": [CSP.SELF], +} + + +class CSPOverrideDecoratorTest(SimpleTestCase): + def test_wrapped_sync_function_is_not_coroutine_function(self): + def sync_view(request): + return HttpResponse() + + wrapped_view = csp_override({})(sync_view) + self.assertIs(iscoroutinefunction(wrapped_view), False) + + def test_wrapped_async_function_is_coroutine_function(self): + async def async_view(request): + return HttpResponse() + + wrapped_view = csp_override({})(async_view) + self.assertIs(iscoroutinefunction(wrapped_view), True) + + def test_decorator_requires_mapping(self): + for config, decorator in product( + [None, 0, False, [], [1, 2, 3], 42, {4, 5}], + (csp_override, csp_report_only_override), + ): + with ( + self.subTest(config=config, decorator=decorator), + self.assertRaisesMessage(TypeError, "CSP config should be a mapping"), + ): + decorator(config) + + def test_csp_override(self): + @csp_override(basic_config) + def sync_view(request): + return HttpResponse("OK") + + response = sync_view(HttpRequest()) + self.assertEqual(response._csp_config, basic_config) + self.assertIs(hasattr(response, "_csp_ro_config"), False) + + async def test_csp_override_async_view(self): + @csp_override(basic_config) + async def async_view(request): + return HttpResponse("OK") + + response = await async_view(HttpRequest()) + self.assertEqual(response._csp_config, basic_config) + self.assertIs(hasattr(response, "_csp_ro_config"), False) + + def test_csp_report_only_override(self): + @csp_report_only_override(basic_config) + def sync_view(request): + return HttpResponse("OK") + + response = sync_view(HttpRequest()) + self.assertEqual(response._csp_ro_config, basic_config) + self.assertIs(hasattr(response, "_csp_config"), False) + + async def test_csp_report_only_override_async_view(self): + @csp_report_only_override(basic_config) + async def async_view(request): + return HttpResponse("OK") + + response = await async_view(HttpRequest()) + self.assertEqual(response._csp_ro_config, basic_config) + self.assertIs(hasattr(response, "_csp_config"), False) + + def test_csp_override_both(self): + @csp_override(basic_config) + @csp_report_only_override(basic_config) + def sync_view(request): + return HttpResponse("OK") + + response = sync_view(HttpRequest()) + self.assertEqual(response._csp_config, basic_config) + self.assertEqual(response._csp_ro_config, basic_config) + + async def test_csp_override_both_async_view(self): + @csp_override(basic_config) + @csp_report_only_override(basic_config) + async def async_view(request): + return HttpResponse("OK") + + response = await async_view(HttpRequest()) + self.assertEqual(response._csp_config, basic_config) + self.assertEqual(response._csp_ro_config, basic_config) diff --git a/tests/middleware/test_csp.py b/tests/middleware/test_csp.py index e7a2452240..3ab732fe2f 100644 --- a/tests/middleware/test_csp.py +++ b/tests/middleware/test_csp.py @@ -106,6 +106,75 @@ class CSPMiddlewareTest(SimpleTestCase): self.assertNotIn(CSP.HEADER_REPORT_ONLY, response) +@override_settings( + MIDDLEWARE=["django.middleware.csp.ContentSecurityPolicyMiddleware"], + ROOT_URLCONF="middleware.urls", + SECURE_CSP=basic_config, + SECURE_CSP_REPORT_ONLY=basic_config, +) +class CSPMiddlewareWithDecoratedViewsTest(SimpleTestCase): + def test_no_decorators(self): + response = self.client.get("/csp-base/") + self.assertEqual(response[CSP.HEADER_ENFORCE], basic_policy) + self.assertEqual(response[CSP.HEADER_REPORT_ONLY], basic_policy) + + def test_csp_disabled_enforced(self): + """ + `csp_override({})` only disables the enforced CSP header. + """ + response = self.client.get("/csp-disabled-enforced/") + self.assertNotIn(CSP.HEADER_ENFORCE, response) + self.assertEqual(response[CSP.HEADER_REPORT_ONLY], basic_policy) + + def test_csp_report_only_disabled(self): + """ + `csp_report_only_override({})` only disables the report-only header. + """ + response = self.client.get("/csp-disabled-report-only/") + self.assertNotIn(CSP.HEADER_REPORT_ONLY, response) + self.assertEqual(response[CSP.HEADER_ENFORCE], basic_policy) + + def test_csp_disabled_both(self): + """ + Using both CSP decorators with empty mappings will clear both headers. + """ + response = self.client.get("/csp-disabled-both/") + self.assertNotIn(CSP.HEADER_ENFORCE, response) + self.assertNotIn(CSP.HEADER_REPORT_ONLY, response) + + def test_csp_override_enforced(self): + """ + `csp_override` only overrides the enforced header. + """ + response = self.client.get("/csp-override-enforced/") + self.assertEqual( + response[CSP.HEADER_ENFORCE], "default-src 'self'; img-src 'self' data:" + ) + self.assertEqual(response[CSP.HEADER_REPORT_ONLY], basic_policy) + + def test_csp_report_only_override(self): + """ + `csp_report_only_override` only overrides the report-only header. + """ + response = self.client.get("/csp-override-report-only/") + self.assertEqual( + response[CSP.HEADER_REPORT_ONLY], "default-src 'self'; img-src 'self' data:" + ) + self.assertEqual(response[CSP.HEADER_ENFORCE], basic_policy) + + def test_csp_override_both_decorator(self): + """ + Using both CSP decorators overrides both CSP Django settings. + """ + response = self.client.get("/csp-override-both/") + self.assertEqual( + response[CSP.HEADER_ENFORCE], "default-src 'self'; img-src 'self' data:" + ) + self.assertEqual( + response[CSP.HEADER_REPORT_ONLY], "default-src 'self'; img-src 'self' data:" + ) + + @override_settings( ROOT_URLCONF="middleware.urls", SECURE_CSP_REPORT_ONLY={ diff --git a/tests/middleware/urls.py b/tests/middleware/urls.py index 37120c7a54..bbd68d2050 100644 --- a/tests/middleware/urls.py +++ b/tests/middleware/urls.py @@ -17,5 +17,11 @@ urlpatterns = [ path("csp-report/", views.csp_report_view), path("csp-base/", views.empty_view), path("csp-nonce/", views.csp_nonce), + path("csp-disabled-both/", views.csp_disabled_both), + path("csp-disabled-enforced/", views.csp_disabled_enforced), + path("csp-disabled-report-only/", views.csp_disabled_ro), + path("csp-override-both/", views.csp_override_both), + path("csp-override-enforced/", views.csp_override_enforced), + path("csp-override-report-only/", views.csp_override_report_only), path("csp-500/", views.csp_500), ] diff --git a/tests/middleware/views.py b/tests/middleware/views.py index 6dc3ca24c7..716ddec5fd 100644 --- a/tests/middleware/views.py +++ b/tests/middleware/views.py @@ -3,9 +3,11 @@ import sys from django.http import HttpResponse from django.middleware.csp import get_nonce +from django.utils.csp import CSP from django.utils.decorators import method_decorator from django.views.debug import technical_500_response from django.views.decorators.common import no_append_slash +from django.views.decorators.csp import csp_override, csp_report_only_override from django.views.decorators.csrf import csrf_exempt from django.views.generic import View @@ -29,6 +31,44 @@ def csp_nonce(request): return HttpResponse(get_nonce(request)) +@csp_override({}) +def csp_disabled_enforced(request): + return HttpResponse() + + +@csp_report_only_override({}) +def csp_disabled_ro(request): + return HttpResponse() + + +@csp_override({}) +@csp_report_only_override({}) +def csp_disabled_both(request): + return HttpResponse() + + +csp_policy_override = { + "default-src": [CSP.SELF], + "img-src": [CSP.SELF, "data:"], +} + + +@csp_override(csp_policy_override) +def csp_override_enforced(request): + return HttpResponse() + + +@csp_report_only_override(csp_policy_override) +def csp_override_report_only(request): + return HttpResponse() + + +@csp_override(csp_policy_override) +@csp_report_only_override(csp_policy_override) +def csp_override_both(request): + return HttpResponse() + + def csp_500(request): try: raise Exception -- cgit v1.3