summaryrefslogtreecommitdiff
path: root/django
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 /django
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 'django')
-rw-r--r--django/conf/global_settings.py6
-rw-r--r--django/core/checks/security/base.py21
-rw-r--r--django/middleware/csp.py36
-rw-r--r--django/template/context_processors.py8
-rw-r--r--django/utils/csp.py110
5 files changed, 181 insertions, 0 deletions
diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py
index 672c46b88f..a414d1428c 100644
--- a/django/conf/global_settings.py
+++ b/django/conf/global_settings.py
@@ -663,6 +663,12 @@ SECURE_REFERRER_POLICY = "same-origin"
SECURE_SSL_HOST = None
SECURE_SSL_REDIRECT = False
+##################
+# CSP MIDDLEWARE #
+##################
+SECURE_CSP = {}
+SECURE_CSP_REPORT_ONLY = {}
+
# RemovedInDjango70Warning: A transitional setting helpful in early adoption of
# HTTPS as the default protocol in urlize and urlizetrunc when no protocol is
# provided. Set to True to assume HTTPS during the Django 6.x release cycle.
diff --git a/django/core/checks/security/base.py b/django/core/checks/security/base.py
index f85adabd1a..9506052196 100644
--- a/django/core/checks/security/base.py
+++ b/django/core/checks/security/base.py
@@ -141,6 +141,11 @@ E024 = Error(
W025 = Warning(SECRET_KEY_WARNING_MSG, id="security.W025")
+E026 = Error(
+ "The Content Security Policy setting '%s' must be a dictionary (got %r instead).",
+ id="security.E026",
+)
+
def _security_middleware():
return "django.middleware.security.SecurityMiddleware" in settings.MIDDLEWARE
@@ -281,3 +286,19 @@ def check_cross_origin_opener_policy(app_configs, **kwargs):
):
return [E024]
return []
+
+
+@register(Tags.security)
+def check_csp_settings(app_configs, **kwargs):
+ """
+ Validate that CSP settings are properly configured when enabled.
+
+ Ensures both SECURE_CSP and SECURE_CSP_REPORT_ONLY are dictionaries.
+ """
+ # CSP settings must be a dictionary or None.
+ return [
+ Error(E026.msg % (name, value), id=E026.id)
+ for name in ("SECURE_CSP", "SECURE_CSP_REPORT_ONLY")
+ if (value := getattr(settings, name, None)) is not None
+ and not isinstance(value, dict)
+ ]
diff --git a/django/middleware/csp.py b/django/middleware/csp.py
new file mode 100644
index 0000000000..e1c66ada5a
--- /dev/null
+++ b/django/middleware/csp.py
@@ -0,0 +1,36 @@
+from http import HTTPStatus
+
+from django.conf import settings
+from django.utils.csp import CSP, LazyNonce, build_policy
+from django.utils.deprecation import MiddlewareMixin
+
+
+def get_nonce(request):
+ return getattr(request, "_csp_nonce", None)
+
+
+class ContentSecurityPolicyMiddleware(MiddlewareMixin):
+ def process_request(self, request):
+ request._csp_nonce = LazyNonce()
+
+ def process_response(self, request, response):
+ # In DEBUG mode, exclude CSP headers for specific status codes that
+ # trigger the debug view.
+ exempted_status_codes = {
+ HTTPStatus.NOT_FOUND,
+ HTTPStatus.INTERNAL_SERVER_ERROR,
+ }
+ if settings.DEBUG and response.status_code in exempted_status_codes:
+ return response
+
+ nonce = get_nonce(request)
+ for header, config in [
+ (CSP.HEADER_ENFORCE, settings.SECURE_CSP),
+ (CSP.HEADER_REPORT_ONLY, settings.SECURE_CSP_REPORT_ONLY),
+ ]:
+ # If headers are already set on the response, don't overwrite them.
+ # This allows for views to set their own CSP headers as needed.
+ if config and header not in response:
+ response.headers[str(header)] = build_policy(config, nonce)
+
+ return response
diff --git a/django/template/context_processors.py b/django/template/context_processors.py
index 32753032fc..f9e5f218e4 100644
--- a/django/template/context_processors.py
+++ b/django/template/context_processors.py
@@ -10,6 +10,7 @@ of a DjangoTemplates backend and used by RequestContext.
import itertools
from django.conf import settings
+from django.middleware.csp import get_nonce
from django.middleware.csrf import get_token
from django.utils.functional import SimpleLazyObject, lazy
@@ -87,3 +88,10 @@ def media(request):
def request(request):
return {"request": request}
+
+
+def csp(request):
+ """
+ Add the CSP nonce to the context.
+ """
+ return {"csp_nonce": get_nonce(request)}
diff --git a/django/utils/csp.py b/django/utils/csp.py
new file mode 100644
index 0000000000..b989a47c23
--- /dev/null
+++ b/django/utils/csp.py
@@ -0,0 +1,110 @@
+import secrets
+from enum import StrEnum
+
+from django.utils.functional import SimpleLazyObject, empty
+
+
+class CSP(StrEnum):
+ """
+ Content Security Policy constants for directive values and special tokens.
+
+ These constants represent:
+ 1. Standard quoted string values from the CSP spec (e.g., 'self', 'unsafe-inline')
+ 2. Special placeholder tokens (NONCE) that get replaced by the middleware
+
+ Using this enum instead of raw strings provides better type checking,
+ autocompletion, and protection against common mistakes like:
+
+ - Typos (e.g., 'noone' instead of 'none')
+ - Missing quotes (e.g., ["self"] instead of ["'self'"])
+ - Inconsistent quote styles (e.g., ["'self'", "\"unsafe-inline\""])
+
+ Example usage in Django settings:
+
+ SECURE_CSP = {
+ "default-src": [CSP.NONE],
+ "script-src": [CSP.SELF, CSP.NONCE],
+ }
+
+ """
+
+ # HTTP Headers.
+ HEADER_ENFORCE = "Content-Security-Policy"
+ HEADER_REPORT_ONLY = "Content-Security-Policy-Report-Only"
+
+ # Standard CSP directive values.
+ NONE = "'none'"
+ REPORT_SAMPLE = "'report-sample'"
+ SELF = "'self'"
+ STRICT_DYNAMIC = "'strict-dynamic'"
+ UNSAFE_EVAL = "'unsafe-eval'"
+ UNSAFE_HASHES = "'unsafe-hashes'"
+ UNSAFE_INLINE = "'unsafe-inline'"
+ WASM_UNSAFE_EVAL = "'wasm-unsafe-eval'"
+
+ # Special placeholder that gets replaced by the middleware.
+ # The value itself is arbitrary and should not be mistaken for a real nonce.
+ NONCE = "<CSP_NONCE_SENTINEL>"
+
+
+class LazyNonce(SimpleLazyObject):
+ """
+ Lazily generates a cryptographically secure nonce string, for use in CSP headers.
+
+ The nonce is only generated when first accessed (e.g., via string
+ interpolation or inside a template).
+
+ The nonce will evaluate as `True` if it has been generated, and `False` if
+ it has not. This is useful for third-party Django libraries that want to
+ support CSP without requiring it.
+
+ Example Django template usage with context processors enabled:
+
+ <script{% if csp_nonce %} nonce="{{ csp_nonce }}"...{% endif %}>
+
+ The `{% if %}` block will only render if the nonce has been evaluated elsewhere.
+
+ """
+
+ def __init__(self):
+ super().__init__(self._generate)
+
+ def _generate(self):
+ return secrets.token_urlsafe(16)
+
+ def __bool__(self):
+ return self._wrapped is not empty
+
+
+def build_policy(config, nonce=None):
+ policy = []
+
+ for directive, values in config.items():
+ if values in (None, False):
+ continue
+
+ if values is True:
+ rendered_value = ""
+ else:
+ if isinstance(values, set):
+ # Sort values for consistency, preventing cache invalidation
+ # between requests and ensuring reliable browser caching.
+ values = sorted(values)
+ elif not isinstance(values, list | tuple):
+ values = [values]
+
+ # Replace the nonce sentinel with the actual nonce values, if the
+ # sentinel is found and a nonce is provided. Otherwise, remove it.
+ if (has_sentinel := CSP.NONCE in values) and nonce:
+ values = [f"'nonce-{nonce}'" if v == CSP.NONCE else v for v in values]
+ elif has_sentinel:
+ values = [v for v in values if v != CSP.NONCE]
+
+ if not values:
+ continue
+
+ rendered_value = " ".join(values)
+
+ policy.append(f"{directive} {rendered_value}".rstrip())
+
+ return "; ".join(policy)