From 550822bceea227b07445d1852c4376b663c09ea4 Mon Sep 17 00:00:00 2001 From: Rob Hudson Date: Sat, 23 Aug 2025 12:23:53 -0700 Subject: [PATCH] Fixed #36532 -- Added Content Security Policy view decorators to override or disable policies. Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> --- django/middleware/csp.py | 23 ++++---- django/views/debug.py | 5 ++ django/views/decorators/csp.py | 39 ++++++++++++++ docs/ref/csp.txt | 86 ++++++++++++++++++++++++++++++ docs/releases/6.0.txt | 3 +- docs/topics/async.txt | 2 + tests/decorators/test_csp.py | 95 ++++++++++++++++++++++++++++++++++ tests/middleware/test_csp.py | 69 ++++++++++++++++++++++++ tests/middleware/urls.py | 6 +++ tests/middleware/views.py | 40 ++++++++++++++ 10 files changed, 354 insertions(+), 14 deletions(-) create mode 100644 django/views/decorators/csp.py create mode 100644 tests/decorators/test_csp.py 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 `. + .. _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 `. For in-depth guidance, see the :ref:`CSP security overview ` and the -:doc:`reference docs `. +:doc:`reference docs `, 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 @@ -106,6 +106,75 @@ class CSPMiddlewareTest(SimpleTestCase): self.assertNotIn(CSP.HEADER_REPORT_ONLY, response) +@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={ 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