diff options
| author | Rob Hudson <rob@cogit8.org> | 2025-05-03 10:01:58 -0700 |
|---|---|---|
| committer | nessita <124304+nessita@users.noreply.github.com> | 2025-06-27 15:57:02 -0300 |
| commit | d63241ebc7067fdebbaf704989b34fcd8f26bbe9 (patch) | |
| tree | 07b5a5cb0c70c446f5f0fb9ad2834501fc3d6544 /tests | |
| parent | 3f59711581bd22ebd0f13fb040b15b69c0eee21f (diff) | |
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>
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/check_framework/test_security.py | 53 | ||||
| -rw-r--r-- | tests/context_processors/templates/context_processors/csp_nonce.html | 17 | ||||
| -rw-r--r-- | tests/context_processors/tests.py | 65 | ||||
| -rw-r--r-- | tests/context_processors/urls.py | 1 | ||||
| -rw-r--r-- | tests/context_processors/views.py | 4 | ||||
| -rw-r--r-- | tests/middleware/test_csp.py | 135 | ||||
| -rw-r--r-- | tests/middleware/urls.py | 7 | ||||
| -rw-r--r-- | tests/middleware/views.py | 28 | ||||
| -rw-r--r-- | tests/utils_tests/test_csp.py | 166 |
9 files changed, 475 insertions, 1 deletions
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 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>CSP Nonce Test</title> +</head> +<body> + <h1>CSP Nonce Test</h1> + <p>CSP Nonce is present: {{ csp_nonce }}</p> + <script nonce="{{ csp_nonce }}"> + console.log("This script is allowed to run due to the nonce."); + </script> + <script> + console.log("This script might be blocked by CSP if a nonce is required."); + </script> +</body> +</html> 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'<script nonce="{nonce}">', response.text) + + def test_csp_nonce_length(self): + response = self.client.get("/csp_nonce/") + nonce = response.context["csp_nonce"] + self.assertEqual(len(nonce), 22) # Based on secrets.token_urlsafe of 16 bytes. diff --git a/tests/context_processors/urls.py b/tests/context_processors/urls.py index 4ca298c922..f3ee496907 100644 --- a/tests/context_processors/urls.py +++ b/tests/context_processors/urls.py @@ -5,4 +5,5 @@ from . import views urlpatterns = [ path("request_attrs/", views.request_processor), path("debug/", views.debug_processor), + path("csp_nonce/", views.csp_nonce_processor), ] diff --git a/tests/context_processors/views.py b/tests/context_processors/views.py index 81710c90eb..a78acffb87 100644 --- a/tests/context_processors/views.py +++ b/tests/context_processors/views.py @@ -13,3 +13,7 @@ def debug_processor(request): "other_debug_objects": DebugObject.objects.using("other"), } return render(request, "context_processors/debug.html", context) + + +def csp_nonce_processor(request): + return render(request, "context_processors/csp_nonce.html") diff --git a/tests/middleware/test_csp.py b/tests/middleware/test_csp.py new file mode 100644 index 0000000000..de55f0c6a0 --- /dev/null +++ b/tests/middleware/test_csp.py @@ -0,0 +1,135 @@ +import time + +from utils_tests.test_csp import basic_config, basic_policy + +from django.contrib.staticfiles.testing import StaticLiveServerTestCase +from django.test import SimpleTestCase +from django.test.selenium import SeleniumTestCase +from django.test.utils import modify_settings, override_settings +from django.utils.csp import CSP + +from .views import csp_reports + + +@override_settings( + MIDDLEWARE=["django.middleware.csp.ContentSecurityPolicyMiddleware"], + ROOT_URLCONF="middleware.urls", +) +class CSPMiddlewareTest(SimpleTestCase): + @override_settings(SECURE_CSP=None, SECURE_CSP_REPORT_ONLY=None) + def test_csp_defaults_off(self): + response = self.client.get("/csp-base/") + self.assertNotIn(CSP.HEADER_ENFORCE, response) + self.assertNotIn(CSP.HEADER_REPORT_ONLY, response) + + @override_settings(SECURE_CSP=basic_config, SECURE_CSP_REPORT_ONLY=None) + def test_csp_basic(self): + """ + With SECURE_CSP set to a valid value, the middleware adds a + "Content-Security-Policy" header to the response. + """ + response = self.client.get("/csp-base/") + self.assertEqual(response[CSP.HEADER_ENFORCE], basic_policy) + self.assertNotIn(CSP.HEADER_REPORT_ONLY, response) + + @override_settings(SECURE_CSP={"default-src": [CSP.SELF, CSP.NONCE]}) + def test_csp_basic_with_nonce(self): + """ + Test the nonce is added to the header and matches what is in the view. + """ + response = self.client.get("/csp-nonce/") + nonce = response.text + self.assertTrue(nonce) + self.assertEqual( + response[CSP.HEADER_ENFORCE], f"default-src 'self' 'nonce-{nonce}'" + ) + + @override_settings(SECURE_CSP={"default-src": [CSP.SELF, CSP.NONCE]}) + def test_csp_basic_with_nonce_but_unused(self): + """ + Test if `request.csp_nonce` is never accessed, it is not added to the header. + """ + response = self.client.get("/csp-base/") + nonce = response.text + self.assertIsNotNone(nonce) + self.assertEqual(response[CSP.HEADER_ENFORCE], basic_policy) + + @override_settings(SECURE_CSP=None, SECURE_CSP_REPORT_ONLY=basic_config) + def test_csp_report_only_basic(self): + """ + With SECURE_CSP_REPORT_ONLY set to a valid value, the middleware adds a + "Content-Security-Policy-Report-Only" header to the response. + """ + response = self.client.get("/csp-base/") + self.assertEqual(response[CSP.HEADER_REPORT_ONLY], basic_policy) + self.assertNotIn(CSP.HEADER_ENFORCE, response) + + @override_settings( + SECURE_CSP=basic_config, + SECURE_CSP_REPORT_ONLY=basic_config, + ) + def test_csp_both(self): + """ + If both SECURE_CSP and SECURE_CSP_REPORT_ONLY are set, the middleware + adds both headers to the response. + """ + response = self.client.get("/csp-base/") + self.assertEqual(response[CSP.HEADER_ENFORCE], basic_policy) + self.assertEqual(response[CSP.HEADER_REPORT_ONLY], basic_policy) + + @override_settings( + DEBUG=True, + SECURE_CSP=basic_config, + SECURE_CSP_REPORT_ONLY=basic_config, + ) + def test_csp_404_debug_view(self): + """ + Test that the CSP headers are not added to the debug view. + """ + response = self.client.get("/csp-404/") + self.assertNotIn(CSP.HEADER_ENFORCE, response) + self.assertNotIn(CSP.HEADER_REPORT_ONLY, response) + + @override_settings( + DEBUG=True, + SECURE_CSP=basic_config, + SECURE_CSP_REPORT_ONLY=basic_config, + ) + def test_csp_500_debug_view(self): + """ + Test that the CSP headers are not added to the debug view. + """ + response = self.client.get("/csp-500/") + self.assertNotIn(CSP.HEADER_ENFORCE, response) + self.assertNotIn(CSP.HEADER_REPORT_ONLY, response) + + +@override_settings( + ROOT_URLCONF="middleware.urls", + SECURE_CSP_REPORT_ONLY={ + "default-src": [CSP.NONE], + "img-src": [CSP.SELF], + "script-src": [CSP.SELF], + "style-src": [CSP.SELF], + "report-uri": "/csp-report/", + }, +) +@modify_settings( + MIDDLEWARE={"append": "django.middleware.csp.ContentSecurityPolicyMiddleware"} +) +class CSPSeleniumTestCase(SeleniumTestCase, StaticLiveServerTestCase): + available_apps = ["middleware"] + + def setUp(self): + self.addCleanup(csp_reports.clear) + super().setUp() + + def test_reports_are_generated(self): + url = self.live_server_url + "/csp-failure/" + self.selenium.get(url) + time.sleep(1) # Allow time for the CSP report to be sent. + reports = sorted( + (r["csp-report"]["document-uri"], r["csp-report"]["violated-directive"]) + for r in csp_reports + ) + self.assertEqual(reports, [(url, "img-src"), (url, "style-src-elem")]) diff --git a/tests/middleware/urls.py b/tests/middleware/urls.py index 294b80b192..37120c7a54 100644 --- a/tests/middleware/urls.py +++ b/tests/middleware/urls.py @@ -1,4 +1,5 @@ from django.urls import path, re_path +from django.views.debug import default_urlconf from . import views @@ -11,4 +12,10 @@ urlpatterns = [ # Should not append slash. path("sensitive_fbv/", views.sensitive_fbv), path("sensitive_cbv/", views.SensitiveCBV.as_view()), + # Used in CSP tests. + path("csp-failure/", default_urlconf), + path("csp-report/", views.csp_report_view), + path("csp-base/", views.empty_view), + path("csp-nonce/", views.csp_nonce), + path("csp-500/", views.csp_500), ] diff --git a/tests/middleware/views.py b/tests/middleware/views.py index 1de2edfd1b..6dc3ca24c7 100644 --- a/tests/middleware/views.py +++ b/tests/middleware/views.py @@ -1,6 +1,12 @@ +import json +import sys + from django.http import HttpResponse +from django.middleware.csp import get_nonce 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.csrf import csrf_exempt from django.views.generic import View @@ -17,3 +23,25 @@ def sensitive_fbv(request, *args, **kwargs): class SensitiveCBV(View): def get(self, *args, **kwargs): return HttpResponse() + + +def csp_nonce(request): + return HttpResponse(get_nonce(request)) + + +def csp_500(request): + try: + raise Exception + except Exception: + return technical_500_response(request, *sys.exc_info()) + + +csp_reports = [] + + +@csrf_exempt +def csp_report_view(request): + if request.method == "POST": + data = json.loads(request.body) + csp_reports.append(data) + return HttpResponse(status=204) diff --git a/tests/utils_tests/test_csp.py b/tests/utils_tests/test_csp.py new file mode 100644 index 0000000000..96c66538d0 --- /dev/null +++ b/tests/utils_tests/test_csp.py @@ -0,0 +1,166 @@ +from secrets import token_urlsafe +from unittest.mock import patch + +from django.test import SimpleTestCase +from django.utils.csp import CSP, LazyNonce, build_policy +from django.utils.functional import empty + +basic_config = { + "default-src": [CSP.SELF], +} +alt_config = { + "default-src": [CSP.SELF, CSP.UNSAFE_INLINE], +} +basic_policy = "default-src 'self'" + + +class CSPConstantsTests(SimpleTestCase): + def test_constants(self): + self.assertEqual(CSP.NONE, "'none'") + self.assertEqual(CSP.REPORT_SAMPLE, "'report-sample'") + self.assertEqual(CSP.SELF, "'self'") + self.assertEqual(CSP.STRICT_DYNAMIC, "'strict-dynamic'") + self.assertEqual(CSP.UNSAFE_EVAL, "'unsafe-eval'") + self.assertEqual(CSP.UNSAFE_HASHES, "'unsafe-hashes'") + self.assertEqual(CSP.UNSAFE_INLINE, "'unsafe-inline'") + self.assertEqual(CSP.WASM_UNSAFE_EVAL, "'wasm-unsafe-eval'") + self.assertEqual(CSP.NONCE, "<CSP_NONCE_SENTINEL>") + + +class CSPBuildPolicyTest(SimpleTestCase): + + def assertPolicyEqual(self, a, b): + parts_a = sorted(a.split("; ")) if a is not None else None + parts_b = sorted(b.split("; ")) if b is not None else None + self.assertEqual(parts_a, parts_b, f"Policies not equal: {a!r} != {b!r}") + + def test_config_empty(self): + self.assertPolicyEqual(build_policy({}), "") + + def test_config_basic(self): + self.assertPolicyEqual(build_policy(basic_config), basic_policy) + + def test_config_multiple_directives(self): + policy = { + "default-src": [CSP.SELF], + "script-src": [CSP.NONE], + } + self.assertPolicyEqual( + build_policy(policy), "default-src 'self'; script-src 'none'" + ) + + def test_config_value_as_string(self): + """ + Test that a single value can be passed as a string. + """ + policy = {"default-src": CSP.SELF} + self.assertPolicyEqual(build_policy(policy), "default-src 'self'") + + def test_config_value_as_tuple(self): + """ + Test that a tuple can be passed as a value. + """ + policy = {"default-src": (CSP.SELF, "foo.com")} + self.assertPolicyEqual(build_policy(policy), "default-src 'self' foo.com") + + def test_config_value_as_set(self): + """ + Test that a set can be passed as a value. + + Sets are often used in Django settings to ensure uniqueness, however, sets are + unordered. The middleware ensures consistency via sorting if a set is passed. + """ + policy = {"default-src": {CSP.SELF, "foo.com", "bar.com"}} + self.assertPolicyEqual( + build_policy(policy), "default-src 'self' bar.com foo.com" + ) + + def test_config_value_none(self): + """ + Test that `None` removes the directive from the policy. + + Useful in cases where the CSP config is scripted in some way or + explicitly not wanting to set a directive. + """ + policy = {"default-src": [CSP.SELF], "script-src": None} + self.assertPolicyEqual(build_policy(policy), basic_policy) + + def test_config_value_boolean_true(self): + policy = {"default-src": [CSP.SELF], "block-all-mixed-content": True} + self.assertPolicyEqual( + build_policy(policy), "default-src 'self'; block-all-mixed-content" + ) + + def test_config_value_boolean_false(self): + policy = {"default-src": [CSP.SELF], "block-all-mixed-content": False} + self.assertPolicyEqual(build_policy(policy), basic_policy) + + def test_config_value_multiple_boolean(self): + policy = { + "default-src": [CSP.SELF], + "block-all-mixed-content": True, + "upgrade-insecure-requests": True, + } + self.assertPolicyEqual( + build_policy(policy), + "default-src 'self'; block-all-mixed-content; upgrade-insecure-requests", + ) + + def test_config_with_nonce_arg(self): + """ + Test when the `CSP.NONCE` is not in the defined policy, the nonce + argument has no effect. + """ + self.assertPolicyEqual(build_policy(basic_config, nonce="abc123"), basic_policy) + + def test_config_with_nonce(self): + policy = {"default-src": [CSP.SELF, CSP.NONCE]} + self.assertPolicyEqual( + build_policy(policy, nonce="abc123"), + "default-src 'self' 'nonce-abc123'", + ) + + def test_config_with_multiple_nonces(self): + policy = { + "default-src": [CSP.SELF, CSP.NONCE], + "script-src": [CSP.SELF, CSP.NONCE], + } + self.assertPolicyEqual( + build_policy(policy, nonce="abc123"), + "default-src 'self' 'nonce-abc123'; script-src 'self' 'nonce-abc123'", + ) + + def test_config_with_empty_directive(self): + policy = {"default-src": []} + self.assertPolicyEqual(build_policy(policy), "") + + +class LazyNonceTests(SimpleTestCase): + def test_generates_on_usage(self): + generated_tokens = [] + nonce = LazyNonce() + self.assertFalse(nonce) + self.assertIs(nonce._wrapped, empty) + + def memento_token_urlsafe(size): + generated_tokens.append(result := token_urlsafe(size)) + return result + + with patch("django.utils.csp.secrets.token_urlsafe", memento_token_urlsafe): + # Force usage, similar to template rendering, to generate the nonce. + val = str(nonce) + + self.assertTrue(nonce) + self.assertEqual(nonce, val) + self.assertIsInstance(nonce, str) + self.assertEqual(len(val), 22) # Based on secrets.token_urlsafe of 16 bytes. + self.assertEqual(generated_tokens, [nonce]) + # Also test the wrapped value. + self.assertEqual(nonce._wrapped, val) + + def test_returns_same_value(self): + nonce = LazyNonce() + first = str(nonce) + second = str(nonce) + + self.assertEqual(first, second) |
