From d63241ebc7067fdebbaf704989b34fcd8f26bbe9 Mon Sep 17 00:00:00 2001 From: Rob Hudson Date: Sat, 3 May 2025 10:01:58 -0700 Subject: Fixed #15727 -- Added Content Security Policy (CSP) support. This initial work adds a pair of settings to configure specific CSP directives for enforcing or reporting policy violations, a new `django.middleware.csp.ContentSecurityPolicyMiddleware` to apply the appropriate headers to responses, and a context processor to support CSP nonces in templates for safely inlining assets. Relevant documentation has been added for the 6.0 release notes, security overview, a new how-to page, and a dedicated reference section. Thanks to the multiple reviewers for their precise and valuable feedback. Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> --- tests/check_framework/test_security.py | 53 +++++++ .../templates/context_processors/csp_nonce.html | 17 +++ tests/context_processors/tests.py | 65 +++++++- tests/context_processors/urls.py | 1 + tests/context_processors/views.py | 4 + tests/middleware/test_csp.py | 135 +++++++++++++++++ tests/middleware/urls.py | 7 + tests/middleware/views.py | 28 ++++ tests/utils_tests/test_csp.py | 166 +++++++++++++++++++++ 9 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 tests/context_processors/templates/context_processors/csp_nonce.html create mode 100644 tests/middleware/test_csp.py create mode 100644 tests/utils_tests/test_csp.py (limited to 'tests') diff --git a/tests/check_framework/test_security.py b/tests/check_framework/test_security.py index cb035a90a4..db21f13ea2 100644 --- a/tests/check_framework/test_security.py +++ b/tests/check_framework/test_security.py @@ -1,3 +1,5 @@ +import itertools + from django.conf import settings from django.core.checks.messages import Error, Warning from django.core.checks.security import base, csrf, sessions @@ -678,3 +680,54 @@ class CheckCrossOriginOpenerPolicyTest(SimpleTestCase): ) def test_with_invalid_coop(self): self.assertEqual(base.check_cross_origin_opener_policy(None), [base.E024]) + + +class CheckSecureCSPTests(SimpleTestCase): + """Tests for the CSP settings check function.""" + + def test_secure_csp_allowed_values(self): + """Check should pass when both CSP settings are None or dicts.""" + allowed_values = (None, {}, {"key": "value"}) + combinations = itertools.product(allowed_values, repeat=2) + for csp_value, csp_report_only_value in combinations: + with ( + self.subTest( + csp_value=csp_value, csp_report_only_value=csp_report_only_value + ), + self.settings( + SECURE_CSP=csp_value, SECURE_CSP_REPORT_ONLY=csp_report_only_value + ), + ): + errors = base.check_csp_settings(None) + self.assertEqual(errors, []) + + def test_secure_csp_invalid_values(self): + """Check should fail when either CSP setting is not a dict.""" + for value in ( + False, + True, + 0, + 42, + "", + "not-a-dict", + set(), + {"a", "b"}, + [], + [1, 2, 3, 4], + ): + with self.subTest(value=value): + csp_error = Error( + base.E026.msg % ("SECURE_CSP", value), id=base.E026.id + ) + with self.settings(SECURE_CSP=value): + errors = base.check_csp_settings(None) + self.assertEqual(errors, [csp_error]) + csp_report_only_error = Error( + base.E026.msg % ("SECURE_CSP_REPORT_ONLY", value), id=base.E026.id + ) + with self.settings(SECURE_CSP_REPORT_ONLY=value): + errors = base.check_csp_settings(None) + self.assertEqual(errors, [csp_report_only_error]) + with self.settings(SECURE_CSP=value, SECURE_CSP_REPORT_ONLY=value): + errors = base.check_csp_settings(None) + self.assertEqual(errors, [csp_error, csp_report_only_error]) diff --git a/tests/context_processors/templates/context_processors/csp_nonce.html b/tests/context_processors/templates/context_processors/csp_nonce.html new file mode 100644 index 0000000000..13612e3840 --- /dev/null +++ b/tests/context_processors/templates/context_processors/csp_nonce.html @@ -0,0 +1,17 @@ + + + + + CSP Nonce Test + + +

CSP Nonce Test

+

CSP Nonce is present: {{ csp_nonce }}

+ + + + diff --git a/tests/context_processors/tests.py b/tests/context_processors/tests.py index ba92ff8b05..737ff3e1cf 100644 --- a/tests/context_processors/tests.py +++ b/tests/context_processors/tests.py @@ -2,7 +2,8 @@ Tests for Django's bundled context processors. """ -from django.test import SimpleTestCase, TestCase, override_settings +from django.test import SimpleTestCase, TestCase, modify_settings, override_settings +from django.utils.csp import CSP @override_settings( @@ -96,3 +97,65 @@ class DebugContextProcessorTests(TestCase): self.assertContains(response, "Third query list: 2") # Check queries for DB connection 'other' self.assertContains(response, "Fourth query list: 3") + + +@override_settings( + ROOT_URLCONF="context_processors.urls", + TEMPLATES=[ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.csp", + ], + }, + } + ], + MIDDLEWARE=[ + "django.middleware.csp.ContentSecurityPolicyMiddleware", + ], + SECURE_CSP={ + "script-src": [CSP.SELF, CSP.NONCE], + }, +) +class CSPContextProcessorTests(TestCase): + """ + Tests for the django.template.context_processors.csp_nonce processor. + """ + + def test_csp_nonce_in_context(self): + response = self.client.get("/csp_nonce/") + self.assertIn("csp_nonce", response.context) + + @modify_settings( + MIDDLEWARE={"remove": "django.middleware.csp.ContentSecurityPolicyMiddleware"} + ) + def test_csp_nonce_in_context_no_middleware(self): + response = self.client.get("/csp_nonce/") + self.assertIn("csp_nonce", response.context) + + def test_csp_nonce_in_header(self): + response = self.client.get("/csp_nonce/") + self.assertIn(CSP.HEADER_ENFORCE, response.headers) + csp_header = response.headers[CSP.HEADER_ENFORCE] + nonce = response.context["csp_nonce"] + self.assertIn(f"'nonce-{nonce}'", csp_header) + + def test_different_nonce_per_request(self): + response1 = self.client.get("/csp_nonce/") + response2 = self.client.get("/csp_nonce/") + self.assertNotEqual( + response1.context["csp_nonce"], + response2.context["csp_nonce"], + ) + + def test_csp_nonce_in_template(self): + response = self.client.get("/csp_nonce/") + nonce = response.context["csp_nonce"] + self.assertIn(f'