summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRob Hudson <rob@cogit8.org>2025-08-23 12:23:53 -0700
committernessita <124304+nessita@users.noreply.github.com>2025-08-28 17:23:48 -0300
commit550822bceea227b07445d1852c4376b663c09ea4 (patch)
treec7b9a032939c7611156b54767c10059d7d6e373c
parent292b9e6fe8f23491680d9cc60f328562e2b1c823 (diff)
Fixed #36532 -- Added Content Security Policy view decorators to override or disable policies.
Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>
-rw-r--r--django/middleware/csp.py23
-rw-r--r--django/views/debug.py5
-rw-r--r--django/views/decorators/csp.py39
-rw-r--r--docs/ref/csp.txt86
-rw-r--r--docs/releases/6.0.txt3
-rw-r--r--docs/topics/async.txt2
-rw-r--r--tests/decorators/test_csp.py95
-rw-r--r--tests/middleware/test_csp.py69
-rw-r--r--tests/middleware/urls.py6
-rw-r--r--tests/middleware/views.py40
10 files changed, 354 insertions, 14 deletions
diff --git a/django/middleware/csp.py b/django/middleware/csp.py
index e1c66ada5a..ba08cfff0c 100644
--- a/django/middleware/csp.py
+++ b/django/middleware/csp.py
@@ -1,5 +1,3 @@
-from http import HTTPStatus
-
from django.conf import settings
from django.utils.csp import CSP, LazyNonce, build_policy
from django.utils.deprecation import MiddlewareMixin
@@ -14,22 +12,21 @@ class ContentSecurityPolicyMiddleware(MiddlewareMixin):
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)
+
+ sentinel = object()
+ if (csp_config := getattr(response, "_csp_config", sentinel)) is sentinel:
+ csp_config = settings.SECURE_CSP
+ if (csp_ro_config := getattr(response, "_csp_ro_config", sentinel)) is sentinel:
+ csp_ro_config = settings.SECURE_CSP_REPORT_ONLY
+
for header, config in [
- (CSP.HEADER_ENFORCE, settings.SECURE_CSP),
- (CSP.HEADER_REPORT_ONLY, settings.SECURE_CSP_REPORT_ONLY),
+ (CSP.HEADER_ENFORCE, csp_config),
+ (CSP.HEADER_REPORT_ONLY, csp_ro_config),
]:
# If headers are already set on the response, don't overwrite them.
# This allows for views to set their own CSP headers as needed.
+ # An empty config means CSP headers are not added to the response.
if config and header not in response:
response.headers[str(header)] = build_policy(config, nonce)
diff --git a/django/views/debug.py b/django/views/debug.py
index 75f30ca601..5a1b4aee91 100644
--- a/django/views/debug.py
+++ b/django/views/debug.py
@@ -18,6 +18,7 @@ from django.utils.encoding import force_str
from django.utils.module_loading import import_string
from django.utils.regex_helper import _lazy_re_compile
from django.utils.version import get_docs_version
+from django.views.decorators.csp import csp_override, csp_report_only_override
from django.views.decorators.debug import coroutine_functions_to_sensitive_variables
# Minimal Django templates engine to render the error templates
@@ -59,6 +60,8 @@ class CallableSettingWrapper:
return repr(self._wrapped)
+@csp_override({})
+@csp_report_only_override({})
def technical_500_response(request, exc_type, exc_value, tb, status_code=500):
"""
Create a technical server error response. The last three arguments are
@@ -606,6 +609,8 @@ class ExceptionReporter:
tb = tb.tb_next
+@csp_override({})
+@csp_report_only_override({})
def technical_404_response(request, exception):
"""Create a technical 404 error response. `exception` is the Http404."""
try:
diff --git a/django/views/decorators/csp.py b/django/views/decorators/csp.py
new file mode 100644
index 0000000000..9033d9cdf0
--- /dev/null
+++ b/django/views/decorators/csp.py
@@ -0,0 +1,39 @@
+from functools import wraps
+
+from asgiref.sync import iscoroutinefunction
+
+
+def _make_csp_decorator(config_attr_name, config_attr_value):
+ """General CSP override decorator factory."""
+
+ if not isinstance(config_attr_value, dict):
+ raise TypeError("CSP config should be a mapping.")
+
+ def decorator(view_func):
+ @wraps(view_func)
+ async def _wrapped_async_view(request, *args, **kwargs):
+ response = await view_func(request, *args, **kwargs)
+ setattr(response, config_attr_name, config_attr_value)
+ return response
+
+ @wraps(view_func)
+ def _wrapped_sync_view(request, *args, **kwargs):
+ response = view_func(request, *args, **kwargs)
+ setattr(response, config_attr_name, config_attr_value)
+ return response
+
+ if iscoroutinefunction(view_func):
+ return _wrapped_async_view
+ return _wrapped_sync_view
+
+ return decorator
+
+
+def csp_override(config):
+ """Override the Content-Security-Policy header for a view."""
+ return _make_csp_decorator("_csp_config", config)
+
+
+def csp_report_only_override(config):
+ """Override the Content-Security-Policy-Report-Only header for a view."""
+ return _make_csp_decorator("_csp_ro_config", config)
diff --git a/docs/ref/csp.txt b/docs/ref/csp.txt
index 3ecee17acd..5d0a9c0ecb 100644
--- a/docs/ref/csp.txt
+++ b/docs/ref/csp.txt
@@ -154,6 +154,92 @@ with the CSP specification.
secure, random nonce that is generated for each request. See detailed
explanation in :ref:`csp-nonce`.
+Decorators
+==========
+
+.. module:: django.views.decorators.csp
+
+Django provides decorators to control the Content Security Policy headers on a
+per-view basis. These allow overriding or disabling the enforced or report-only
+policy for specific views, providing fine-grained control when the global
+settings are not sufficient. Applying these overrides fully replaces the base
+CSP: they do not merge with existing rules. They can be used alongside the
+constants defined in :class:`~django.utils.csp.CSP`.
+
+.. warning::
+
+ Weakening or disabling a CSP policy on any page can compromise the security
+ of the entire site. Because of the "same origin" policy, an attacker could
+ exploit a vulnerability on one page to access other parts of the site.
+
+.. function:: csp_override(config)(view)
+
+ Overrides the ``Content-Security-Policy`` header for the decorated view
+ using directives in the same format as the :setting:`SECURE_CSP` setting.
+
+ The ``config`` argument must be a mapping with the desired CSP directives.
+ If ``config`` is an empty mapping (``{}``), no CSP enforcement header will
+ be added to the response returned by that view, effectively disabling CSP
+ for that view.
+
+ Examples::
+
+ from django.http import HttpResponse
+ from django.utils.csp import CSP
+ from django.views.decorators.csp import csp_override
+
+
+ @csp_override(
+ {
+ "default-src": [CSP.SELF],
+ "img-src": [CSP.SELF, "data:"],
+ }
+ )
+ def my_view(request):
+ return HttpResponse("Custom Content-Security-Policy header applied")
+
+
+ @csp_override({})
+ def my_other_view(request):
+ return HttpResponse("No Content-Security-Policy header added")
+
+
+.. function:: csp_report_only_override(config)(view)
+
+ Overrides the ``Content-Security-Policy-Report-Only`` header for the
+ decorated view using directives in the same format as the
+ :setting:`SECURE_CSP_REPORT_ONLY` setting.
+
+ Like :func:`csp_override`, the ``config`` argument must be a mapping with
+ the desired CSP directives. If ``config`` is an empty mapping (``{}``), no
+ CSP report-only header will be added to the response returned by that view,
+ effectively disabling report-only CSP for that view.
+
+ Examples::
+
+ from django.http import HttpResponse
+ from django.utils.csp import CSP
+ from django.views.decorators.csp import csp_report_only_override
+
+
+ @csp_report_only_override(
+ {
+ "default-src": [CSP.SELF],
+ "img-src": [CSP.SELF, "data:"],
+ "report-uri": "https://mysite.com/csp-report/",
+ }
+ )
+ def my_view(request):
+ return HttpResponse("Custom Content-Security-Policy-Report-Only header applied")
+
+
+ @csp_report_only_override({})
+ def my_other_view(request):
+ return HttpResponse("No Content-Security-Policy-Report-Only header added")
+
+The examples above assume function-based views. For class-based views, see the
+:ref:`guide for decorating class-based views <decorating-class-based-views>`.
+
.. _csp-nonce:
Nonce usage
diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt
index a9fe1873aa..c47faec9d5 100644
--- a/docs/releases/6.0.txt
+++ b/docs/releases/6.0.txt
@@ -72,7 +72,8 @@ The resulting ``Content-Security-Policy`` header would be set to:
To get started, follow the :doc:`CSP how-to guide </howto/csp>`. For in-depth
guidance, see the :ref:`CSP security overview <security-csp>` and the
-:doc:`reference docs </ref/csp>`.
+:doc:`reference docs </ref/csp>`, which include details about decorators to
+override or disable policies on a per-view basis.
Adoption of Python's modern email API
-------------------------------------
diff --git a/docs/topics/async.txt b/docs/topics/async.txt
index 8138103f92..5cf9fd4624 100644
--- a/docs/topics/async.txt
+++ b/docs/topics/async.txt
@@ -82,6 +82,8 @@ view functions:
* :func:`~django.views.decorators.cache.cache_control`
* :func:`~django.views.decorators.cache.never_cache`
* :func:`~django.views.decorators.common.no_append_slash`
+* :func:`~django.views.decorators.csp.csp_override`
+* :func:`~django.views.decorators.csp.csp_report_only_override`
* :func:`~django.views.decorators.csrf.csrf_exempt`
* :func:`~django.views.decorators.csrf.csrf_protect`
* :func:`~django.views.decorators.csrf.ensure_csrf_cookie`
diff --git a/tests/decorators/test_csp.py b/tests/decorators/test_csp.py
new file mode 100644
index 0000000000..2def6b0f79
--- /dev/null
+++ b/tests/decorators/test_csp.py
@@ -0,0 +1,95 @@
+from itertools import product
+
+from asgiref.sync import iscoroutinefunction
+
+from django.http import HttpRequest, HttpResponse
+from django.test import SimpleTestCase
+from django.utils.csp import CSP
+from django.views.decorators.csp import csp_override, csp_report_only_override
+
+basic_config = {
+ "default-src": [CSP.SELF],
+}
+
+
+class CSPOverrideDecoratorTest(SimpleTestCase):
+ def test_wrapped_sync_function_is_not_coroutine_function(self):
+ def sync_view(request):
+ return HttpResponse()
+
+ wrapped_view = csp_override({})(sync_view)
+ self.assertIs(iscoroutinefunction(wrapped_view), False)
+
+ def test_wrapped_async_function_is_coroutine_function(self):
+ async def async_view(request):
+ return HttpResponse()
+
+ wrapped_view = csp_override({})(async_view)
+ self.assertIs(iscoroutinefunction(wrapped_view), True)
+
+ def test_decorator_requires_mapping(self):
+ for config, decorator in product(
+ [None, 0, False, [], [1, 2, 3], 42, {4, 5}],
+ (csp_override, csp_report_only_override),
+ ):
+ with (
+ self.subTest(config=config, decorator=decorator),
+ self.assertRaisesMessage(TypeError, "CSP config should be a mapping"),
+ ):
+ decorator(config)
+
+ def test_csp_override(self):
+ @csp_override(basic_config)
+ def sync_view(request):
+ return HttpResponse("OK")
+
+ response = sync_view(HttpRequest())
+ self.assertEqual(response._csp_config, basic_config)
+ self.assertIs(hasattr(response, "_csp_ro_config"), False)
+
+ async def test_csp_override_async_view(self):
+ @csp_override(basic_config)
+ async def async_view(request):
+ return HttpResponse("OK")
+
+ response = await async_view(HttpRequest())
+ self.assertEqual(response._csp_config, basic_config)
+ self.assertIs(hasattr(response, "_csp_ro_config"), False)
+
+ def test_csp_report_only_override(self):
+ @csp_report_only_override(basic_config)
+ def sync_view(request):
+ return HttpResponse("OK")
+
+ response = sync_view(HttpRequest())
+ self.assertEqual(response._csp_ro_config, basic_config)
+ self.assertIs(hasattr(response, "_csp_config"), False)
+
+ async def test_csp_report_only_override_async_view(self):
+ @csp_report_only_override(basic_config)
+ async def async_view(request):
+ return HttpResponse("OK")
+
+ response = await async_view(HttpRequest())
+ self.assertEqual(response._csp_ro_config, basic_config)
+ self.assertIs(hasattr(response, "_csp_config"), False)
+
+ def test_csp_override_both(self):
+ @csp_override(basic_config)
+ @csp_report_only_override(basic_config)
+ def sync_view(request):
+ return HttpResponse("OK")
+
+ response = sync_view(HttpRequest())
+ self.assertEqual(response._csp_config, basic_config)
+ self.assertEqual(response._csp_ro_config, basic_config)
+
+ async def test_csp_override_both_async_view(self):
+ @csp_override(basic_config)
+ @csp_report_only_override(basic_config)
+ async def async_view(request):
+ return HttpResponse("OK")
+
+ response = await async_view(HttpRequest())
+ self.assertEqual(response._csp_config, basic_config)
+ self.assertEqual(response._csp_ro_config, basic_config)
diff --git a/tests/middleware/test_csp.py b/tests/middleware/test_csp.py
index e7a2452240..3ab732fe2f 100644
--- a/tests/middleware/test_csp.py
+++ b/tests/middleware/test_csp.py
@@ -107,6 +107,75 @@ class CSPMiddlewareTest(SimpleTestCase):
@override_settings(
+ MIDDLEWARE=["django.middleware.csp.ContentSecurityPolicyMiddleware"],
+ ROOT_URLCONF="middleware.urls",
+ SECURE_CSP=basic_config,
+ SECURE_CSP_REPORT_ONLY=basic_config,
+)
+class CSPMiddlewareWithDecoratedViewsTest(SimpleTestCase):
+ def test_no_decorators(self):
+ response = self.client.get("/csp-base/")
+ self.assertEqual(response[CSP.HEADER_ENFORCE], basic_policy)
+ self.assertEqual(response[CSP.HEADER_REPORT_ONLY], basic_policy)
+
+ def test_csp_disabled_enforced(self):
+ """
+ `csp_override({})` only disables the enforced CSP header.
+ """
+ response = self.client.get("/csp-disabled-enforced/")
+ self.assertNotIn(CSP.HEADER_ENFORCE, response)
+ self.assertEqual(response[CSP.HEADER_REPORT_ONLY], basic_policy)
+
+ def test_csp_report_only_disabled(self):
+ """
+ `csp_report_only_override({})` only disables the report-only header.
+ """
+ response = self.client.get("/csp-disabled-report-only/")
+ self.assertNotIn(CSP.HEADER_REPORT_ONLY, response)
+ self.assertEqual(response[CSP.HEADER_ENFORCE], basic_policy)
+
+ def test_csp_disabled_both(self):
+ """
+ Using both CSP decorators with empty mappings will clear both headers.
+ """
+ response = self.client.get("/csp-disabled-both/")
+ self.assertNotIn(CSP.HEADER_ENFORCE, response)
+ self.assertNotIn(CSP.HEADER_REPORT_ONLY, response)
+
+ def test_csp_override_enforced(self):
+ """
+ `csp_override` only overrides the enforced header.
+ """
+ response = self.client.get("/csp-override-enforced/")
+ self.assertEqual(
+ response[CSP.HEADER_ENFORCE], "default-src 'self'; img-src 'self' data:"
+ )
+ self.assertEqual(response[CSP.HEADER_REPORT_ONLY], basic_policy)
+
+ def test_csp_report_only_override(self):
+ """
+ `csp_report_only_override` only overrides the report-only header.
+ """
+ response = self.client.get("/csp-override-report-only/")
+ self.assertEqual(
+ response[CSP.HEADER_REPORT_ONLY], "default-src 'self'; img-src 'self' data:"
+ )
+ self.assertEqual(response[CSP.HEADER_ENFORCE], basic_policy)
+
+ def test_csp_override_both_decorator(self):
+ """
+ Using both CSP decorators overrides both CSP Django settings.
+ """
+ response = self.client.get("/csp-override-both/")
+ self.assertEqual(
+ response[CSP.HEADER_ENFORCE], "default-src 'self'; img-src 'self' data:"
+ )
+ self.assertEqual(
+ response[CSP.HEADER_REPORT_ONLY], "default-src 'self'; img-src 'self' data:"
+ )
+
+
+@override_settings(
ROOT_URLCONF="middleware.urls",
SECURE_CSP_REPORT_ONLY={
"default-src": [CSP.NONE],
diff --git a/tests/middleware/urls.py b/tests/middleware/urls.py
index 37120c7a54..bbd68d2050 100644
--- a/tests/middleware/urls.py
+++ b/tests/middleware/urls.py
@@ -17,5 +17,11 @@ urlpatterns = [
path("csp-report/", views.csp_report_view),
path("csp-base/", views.empty_view),
path("csp-nonce/", views.csp_nonce),
+ path("csp-disabled-both/", views.csp_disabled_both),
+ path("csp-disabled-enforced/", views.csp_disabled_enforced),
+ path("csp-disabled-report-only/", views.csp_disabled_ro),
+ path("csp-override-both/", views.csp_override_both),
+ path("csp-override-enforced/", views.csp_override_enforced),
+ path("csp-override-report-only/", views.csp_override_report_only),
path("csp-500/", views.csp_500),
]
diff --git a/tests/middleware/views.py b/tests/middleware/views.py
index 6dc3ca24c7..716ddec5fd 100644
--- a/tests/middleware/views.py
+++ b/tests/middleware/views.py
@@ -3,9 +3,11 @@ import sys
from django.http import HttpResponse
from django.middleware.csp import get_nonce
+from django.utils.csp import CSP
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.csp import csp_override, csp_report_only_override
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View
@@ -29,6 +31,44 @@ def csp_nonce(request):
return HttpResponse(get_nonce(request))
+@csp_override({})
+def csp_disabled_enforced(request):
+ return HttpResponse()
+
+
+@csp_report_only_override({})
+def csp_disabled_ro(request):
+ return HttpResponse()
+
+
+@csp_override({})
+@csp_report_only_override({})
+def csp_disabled_both(request):
+ return HttpResponse()
+
+
+csp_policy_override = {
+ "default-src": [CSP.SELF],
+ "img-src": [CSP.SELF, "data:"],
+}
+
+
+@csp_override(csp_policy_override)
+def csp_override_enforced(request):
+ return HttpResponse()
+
+
+@csp_report_only_override(csp_policy_override)
+def csp_override_report_only(request):
+ return HttpResponse()
+
+
+@csp_override(csp_policy_override)
+@csp_report_only_override(csp_policy_override)
+def csp_override_both(request):
+ return HttpResponse()
+
+
def csp_500(request):
try:
raise Exception