From 62fa9b8976b9fd74440e9f97c0744feaf3165dc9 Mon Sep 17 00:00:00 2001 From: Milad Zarour Date: Tue, 5 May 2026 23:48:53 +0200 Subject: [PATCH] Fixed #37084 -- Added CSP nonce context processor system check. --- django/core/checks/security/base.py | 58 ++++++++++++++++ docs/ref/checks.txt | 3 + docs/releases/6.1.txt | 5 ++ tests/check_framework/test_security.py | 95 ++++++++++++++++++++++++++ 4 files changed, 161 insertions(+) diff --git a/django/core/checks/security/base.py b/django/core/checks/security/base.py index 51f6df7a3bd1..95dee26c6d83 100644 --- a/django/core/checks/security/base.py +++ b/django/core/checks/security/base.py @@ -1,6 +1,7 @@ from django.conf import settings from django.core.checks import Error, Tags, Warning, register from django.core.exceptions import ImproperlyConfigured +from django.utils.csp import CSP CROSS_ORIGIN_OPENER_POLICY_VALUES = { "same-origin", @@ -145,6 +146,19 @@ id="security.E026", ) +W027 = Warning( + "Your Content Security Policy includes CSP.NONCE and " + "'django.middleware.csp.ContentSecurityPolicyMiddleware' is enabled, but " + "'django.template.context_processors.csp' is not configured. The nonce " + "will appear in the response header but not in rendered templates, so " + "nonce-based protection will not take effect.", + hint=( + "Add 'django.template.context_processors.csp' to the 'context_processors' " + "option of at least one template backend." + ), + id="security.W027", +) + def _security_middleware(): return "django.middleware.security.SecurityMiddleware" in settings.MIDDLEWARE @@ -156,6 +170,36 @@ def _xframe_middleware(): ) +def _csp_middleware(): + return ( + "django.middleware.csp.ContentSecurityPolicyMiddleware" in settings.MIDDLEWARE + ) + + +def _csp_policy_contains_nonce(policy): + try: + policy_values = policy.values() + except AttributeError: + return False + for values in policy_values: + try: + if values == CSP.NONCE or CSP.NONCE in values: + return True + except TypeError: + pass + return False + + +def _csp_context_processor_configured(): + context_processor = "django.template.context_processors.csp" + return any( + isinstance(template, dict) + and context_processor + in template.get("OPTIONS", {}).get("context_processors", []) + for template in getattr(settings, "TEMPLATES", []) + ) + + @register(Tags.security, deploy=True) def check_security_middleware(app_configs, **kwargs): passed_check = _security_middleware() @@ -302,3 +346,17 @@ def check_csp_settings(app_configs, **kwargs): if (value := getattr(settings, name, None)) is not None and not isinstance(value, dict) ] + + +@register(Tags.security) +def check_csp_nonce_context_processor(app_configs, **kwargs): + if ( + _csp_middleware() + and any( + _csp_policy_contains_nonce(getattr(settings, name, None)) + for name in ("SECURE_CSP", "SECURE_CSP_REPORT_ONLY") + ) + and not _csp_context_processor_configured() + ): + return [W027] + return [] diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index c30081ab9a8a..f6fedf87d331 100644 --- a/docs/ref/checks.txt +++ b/docs/ref/checks.txt @@ -604,6 +604,9 @@ configured: imported. * **security.E026**: The CSP setting ```` must be a dictionary (got ```` instead). +* **security.W027**: Your Content Security Policy includes ``CSP.NONCE`` and + :class:`~django.middleware.csp.ContentSecurityPolicyMiddleware` is enabled, + but ``django.template.context_processors.csp`` is not configured. Signals ------- diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 6f175f76d01d..412a99701985 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -223,6 +223,11 @@ CSP :func:`~django.template.context_processors.csp` context processor is configured. See :ref:`csp-nonce` for details. +* A new ``security.W027`` system check warns when + :class:`~django.middleware.csp.ContentSecurityPolicyMiddleware` is enabled + with ``CSP.NONCE`` in a CSP policy but + ``django.template.context_processors.csp`` is not configured. + CSRF ~~~~ diff --git a/tests/check_framework/test_security.py b/tests/check_framework/test_security.py index 0f03845c15b1..c9b27b091470 100644 --- a/tests/check_framework/test_security.py +++ b/tests/check_framework/test_security.py @@ -8,6 +8,7 @@ from django.core.management.utils import get_random_secret_key from django.test import SimpleTestCase from django.test.utils import override_settings +from django.utils.csp import CSP from django.utils.version import PY314 from django.views.generic import View @@ -706,6 +707,23 @@ def test_with_invalid_coop(self): class CheckSecureCSPTests(SimpleTestCase): """Tests for the CSP settings check function.""" + django_templates_with_csp = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "OPTIONS": { + "context_processors": ["django.template.context_processors.csp"], + }, + }, + ] + jinja2_templates_with_csp = [ + { + "BACKEND": "django.template.backends.jinja2.Jinja2", + "OPTIONS": { + "context_processors": ["django.template.context_processors.csp"], + }, + }, + ] + def test_secure_csp_allowed_values(self): """Check should pass when both CSP settings are None or dicts.""" allowed_values = (None, {}, {"key": "value"}) @@ -752,3 +770,80 @@ def test_secure_csp_invalid_values(self): 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]) + + @override_settings( + MIDDLEWARE=["django.middleware.csp.ContentSecurityPolicyMiddleware"], + SECURE_CSP={"script-src": [CSP.SELF, CSP.NONCE]}, + SECURE_CSP_REPORT_ONLY={}, + TEMPLATES=[], + ) + def test_secure_csp_nonce_without_context_processor(self): + self.assertEqual(base.check_csp_nonce_context_processor(None), [base.W027]) + + @override_settings( + MIDDLEWARE=["django.middleware.csp.ContentSecurityPolicyMiddleware"], + SECURE_CSP={}, + SECURE_CSP_REPORT_ONLY={"script-src": [CSP.SELF, CSP.NONCE]}, + TEMPLATES=[], + ) + def test_secure_csp_report_only_nonce_without_context_processor(self): + self.assertEqual(base.check_csp_nonce_context_processor(None), [base.W027]) + + def test_secure_csp_nonce_iterable_values_without_context_processor(self): + tests = ( + frozenset([CSP.SELF, CSP.NONCE]), + (value for value in [CSP.SELF, CSP.NONCE]), + ) + for values in tests: + with ( + self.subTest(values=values), + self.settings( + MIDDLEWARE=[ + "django.middleware.csp.ContentSecurityPolicyMiddleware" + ], + SECURE_CSP={"script-src": values}, + TEMPLATES=[], + ), + ): + self.assertEqual( + base.check_csp_nonce_context_processor(None), [base.W027] + ) + + def test_secure_csp_nonce_context_processor_allowed_values(self): + tests = [ + { + "MIDDLEWARE": ["django.middleware.csp.ContentSecurityPolicyMiddleware"], + "SECURE_CSP": None, + "SECURE_CSP_REPORT_ONLY": None, + "TEMPLATES": [], + }, + { + "MIDDLEWARE": [], + "SECURE_CSP": {"script-src": [CSP.SELF, CSP.NONCE]}, + "TEMPLATES": [], + }, + { + "MIDDLEWARE": ["django.middleware.csp.ContentSecurityPolicyMiddleware"], + "SECURE_CSP": {"script-src": [CSP.SELF]}, + "TEMPLATES": [], + }, + { + "MIDDLEWARE": ["django.middleware.csp.ContentSecurityPolicyMiddleware"], + "SECURE_CSP": {"upgrade-insecure-requests": True}, + "TEMPLATES": [], + }, + { + "MIDDLEWARE": ["django.middleware.csp.ContentSecurityPolicyMiddleware"], + "SECURE_CSP": {"script-src": [CSP.SELF, CSP.NONCE]}, + "TEMPLATES": self.django_templates_with_csp, + }, + { + "MIDDLEWARE": ["django.middleware.csp.ContentSecurityPolicyMiddleware"], + "SECURE_CSP": {"script-src": [CSP.SELF, CSP.NONCE]}, + "TEMPLATES": self.jinja2_templates_with_csp, + }, + ] + for settings_overrides in tests: + with self.subTest(settings_overrides=settings_overrides): + with self.settings(**settings_overrides): + self.assertEqual(base.check_csp_nonce_context_processor(None), [])