summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorRob Hudson <rob@cogit8.org>2025-05-03 10:01:58 -0700
committernessita <124304+nessita@users.noreply.github.com>2025-06-27 15:57:02 -0300
commitd63241ebc7067fdebbaf704989b34fcd8f26bbe9 (patch)
tree07b5a5cb0c70c446f5f0fb9ad2834501fc3d6544 /tests
parent3f59711581bd22ebd0f13fb040b15b69c0eee21f (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.py53
-rw-r--r--tests/context_processors/templates/context_processors/csp_nonce.html17
-rw-r--r--tests/context_processors/tests.py65
-rw-r--r--tests/context_processors/urls.py1
-rw-r--r--tests/context_processors/views.py4
-rw-r--r--tests/middleware/test_csp.py135
-rw-r--r--tests/middleware/urls.py7
-rw-r--r--tests/middleware/views.py28
-rw-r--r--tests/utils_tests/test_csp.py166
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)