Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions django/core/checks/security/base.py
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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 []
3 changes: 3 additions & 0 deletions docs/ref/checks.txt
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,9 @@ configured:
imported.
* **security.E026**: The CSP setting ``<SETTING_NAME>`` must be a dictionary
(got ``<value>`` 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
-------
Expand Down
5 changes: 5 additions & 0 deletions docs/releases/6.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~

Expand Down
95 changes: 95 additions & 0 deletions tests/check_framework/test_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"})
Expand Down Expand Up @@ -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), [])
Loading