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 /django | |
| 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 'django')
| -rw-r--r-- | django/conf/global_settings.py | 6 | ||||
| -rw-r--r-- | django/core/checks/security/base.py | 21 | ||||
| -rw-r--r-- | django/middleware/csp.py | 36 | ||||
| -rw-r--r-- | django/template/context_processors.py | 8 | ||||
| -rw-r--r-- | django/utils/csp.py | 110 |
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) |
