diff --git a/django/middleware/csp.py b/django/middleware/csp.py index e1c66ada5a64..d4ca982df17e 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,23 +12,24 @@ 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), + for config, header, disabled in [ + ( + getattr(response, "_csp_config", None) or settings.SECURE_CSP, + CSP.HEADER_ENFORCE, + getattr(response, "_csp_disabled", False), + ), + ( + getattr(response, "_csp_config_ro", None) + or settings.SECURE_CSP_REPORT_ONLY, + CSP.HEADER_REPORT_ONLY, + getattr(response, "_csp_disabled_ro", False), + ), ]: - # 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: + # Only set CSP headers if they are not present on the response, + # and if CSP is not disabled via the `@csp_disabled` decorator. + # This allows views to customize or disable CSP headers as needed. + if config and header not in response and not disabled: response.headers[str(header)] = build_policy(config, nonce) return response diff --git a/django/views/debug.py b/django/views/debug.py index 75f30ca60138..70248b9383ef 100644 --- a/django/views/debug.py +++ b/django/views/debug.py @@ -18,6 +18,7 @@ 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_disabled from django.views.decorators.debug import coroutine_functions_to_sensitive_variables # Minimal Django templates engine to render the error templates @@ -59,6 +60,7 @@ def __repr__(self): return repr(self._wrapped) +@csp_disabled 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 +608,7 @@ def get_exception_traceback_frames(self, exc_value, tb): tb = tb.tb_next +@csp_disabled 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 000000000000..5ef7c78d8621 --- /dev/null +++ b/django/views/decorators/csp.py @@ -0,0 +1,67 @@ +from functools import wraps + +from asgiref.sync import iscoroutinefunction + + +def csp_override(config, enforced=True, report_only=True): + def _set_config(response, config, enforced, report_only): + if enforced: + response._csp_config = config + if report_only: + response._csp_config_ro = config + return response + + def decorator(view_func): + @wraps(view_func) + async def _wrapped_async_view(request, *args, **kwargs): + response = await view_func(request, *args, **kwargs) + response = _set_config(response, config, enforced, report_only) + return response + + @wraps(view_func) + def _wrapped_sync_view(request, *args, **kwargs): + response = view_func(request, *args, **kwargs) + response = _set_config(response, config, enforced, report_only) + return response + + # Determine whether to wrap as async or sync function + if iscoroutinefunction(view_func): + return _wrapped_async_view + return _wrapped_sync_view + + return decorator + + +def csp_disabled(enforced=True, report_only=True): + def _set_disabled(response, enforced, report_only): + if enforced: + response._csp_disabled = enforced + if report_only: + response._csp_disabled_ro = report_only + return response + + def decorator(view_func): + @wraps(view_func) + async def _wrapped_async_view(request, *args, **kwargs): + response = await view_func(request, *args, **kwargs) + response = _set_disabled(response, enforced, report_only) + return response + + @wraps(view_func) + def _wrapped_sync_view(request, *args, **kwargs): + response = view_func(request, *args, **kwargs) + response = _set_disabled(response, enforced, report_only) + return response + + # Determine whether to wrap as async or sync function + if iscoroutinefunction(view_func): + return _wrapped_async_view + return _wrapped_sync_view + + # Check if called directly or with arguments + if callable(enforced): + # When no args passed, `enforced` is the view func. + return decorator(enforced) + + # Called with arguments, return the actual decorator + return decorator diff --git a/docs/ref/csp.txt b/docs/ref/csp.txt index e3666c912995..5b4aaeded189 100644 --- a/docs/ref/csp.txt +++ b/docs/ref/csp.txt @@ -153,6 +153,101 @@ 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 + +The examples below assume you are using function-based views. If you +are working with class-based views, you can refer to :ref:`Decorating +class-based views`. + +.. warning:: + + Weakening or excluding a CSP policy on any page can compromise the + security of the entire site. Due to the "same origin" policy, an + attacker can exploit a vulnerability on one page to access other parts + of the site. + +.. function:: csp_disabled(enforced=True, report_only=True)(view) + + This decorator marks a view as being disabled from the Content Security Policy + protection ensured by the middleware. Example:: + + from django.http import HttpResponse + from django.views.decorators.csp import csp_disabled + + + @csp_disabled + def my_view(request): + return HttpResponse("This view will not have either CSP headers") + + This decorator takes optional boolean arguments ``enforced`` and + ``report_only`` to customize which header is disabled. By default, both + headers are disabled. Example:: + + from django.http import HttpResponse + from django.views.decorators.csp import csp_disabled + + + @csp_disabled(report_only=False) + def my_view(request): + return HttpResponse("This view will only have an enforced CSP header") + + + @csp_disabled(enforced=False) + def another_view(request): + return HttpResponse("This view will only have a report-only CSP header") + +.. function:: csp_override(config, enforced=True, report_only=True)(view) + + This decorator allows you to override the Content Security Policy for a + specific view. It takes a dictionary as an argument, similar to the + ``SECURE_CSP`` and ``SECURE_CSP_REPORT_ONLY`` settings. This decorator also + takes optional boolean arguments ``enforced`` and ``report_only`` to + customize which header is overridden. By default, both headers are + overridden. Example:: + + 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("Both CSP headers will be overridden") + + + @csp_override( + { + "default-src": [CSP.SELF], + "img-src": [CSP.SELF, "data:"], + }, + report_only=False, + ) + def enforced_only_view(request): + return HttpResponse("Only the enforced CSP header will be overridden") + + + @csp_override( + { + "default-src": [CSP.SELF], + "img-src": [CSP.SELF, "data:"], + }, + enforced=False, + ) + def report_only_view(request): + return HttpResponse("Only the report-only CSP header will be overridden") + +These decorators provide fine-grained control over the Content Security Policy +on a per-view basis, allowing you to customize the policy as needed for specific +parts of your application. + .. _csp-nonce: Nonce usage diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt index 7997ff3a388d..6875d1805b17 100644 --- a/docs/releases/6.0.txt +++ b/docs/releases/6.0.txt @@ -70,6 +70,10 @@ The resulting ``Content-Security-Policy`` header would be set to: default-src 'self'; script-src 'self' 'nonce-SECRET'; img-src 'self' https: +Per-view policy customization with can be achieved via the +:func:`~django.views.decorators.csp.csp_disabled` and +:func:`~django.views.decorators.csp.csp_override` decorators. + 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 `. diff --git a/docs/topics/async.txt b/docs/topics/async.txt index c5cbabeea784..244f553de77b 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_disabled` +* :func:`~django.views.decorators.csp.csp_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 000000000000..4406b6ec6ead --- /dev/null +++ b/tests/decorators/test_csp.py @@ -0,0 +1,186 @@ +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_disabled, csp_override + +basic_config = { + "default-src": [CSP.SELF], +} + + +class CSPDisabledDecoratorTest(SimpleTestCase): + def test_wrapped_sync_function_is_not_coroutine_function(self): + def sync_view(request): + return HttpResponse() + + wrapped_view = csp_disabled()(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_disabled()(async_view) + self.assertIs(iscoroutinefunction(wrapped_view), True) + + def test_csp_disabled_both(self): + @csp_disabled + def sync_view(request): + return HttpResponse("OK") + + response = sync_view(HttpRequest()) + self.assertTrue(response._csp_disabled) + self.assertTrue(response._csp_disabled_ro) + + async def test_csp_disabled_both_async_view(self): + @csp_disabled + async def async_view(request): + return HttpResponse("OK") + + response = await async_view(HttpRequest()) + self.assertTrue(response._csp_disabled) + self.assertTrue(response._csp_disabled_ro) + + def test_csp_disabled_enforced(self): + @csp_disabled(report_only=False) + def sync_view(request): + return HttpResponse("OK") + + response = sync_view(HttpRequest()) + self.assertTrue(response._csp_disabled) + self.assertIsNone(getattr(response, "_csp_disabled_ro", None)) + + async def test_csp_disabled_enforced_async_view(self): + @csp_disabled(report_only=False) + async def async_view(request): + return HttpResponse("OK") + + response = await async_view(HttpRequest()) + self.assertTrue(response._csp_disabled) + self.assertIsNone(getattr(response, "_csp_disabled_ro", None)) + + def test_csp_disabled_report_only(self): + @csp_disabled(enforced=False) + def sync_view(request): + return HttpResponse("OK") + + response = sync_view(HttpRequest()) + self.assertIsNone(getattr(response, "_csp_disabled", None)) + self.assertTrue(response._csp_disabled_ro) + + async def test_csp_disabled_report_only_async_view(self): + @csp_disabled(enforced=False) + async def async_view(request): + return HttpResponse("OK") + + response = await async_view(HttpRequest()) + self.assertIsNone(getattr(response, "_csp_disabled", None)) + self.assertTrue(response._csp_disabled_ro) + + def test_csp_disabled_neither(self): + @csp_disabled(enforced=False, report_only=False) + def sync_view(request): + return HttpResponse("OK") + + response = sync_view(HttpRequest()) + self.assertIsNone(getattr(response, "_csp_disabled", None)) + self.assertIsNone(getattr(response, "_csp_disabled_ro", None)) + + async def test_csp_disabled_neither_async_view(self): + @csp_disabled(enforced=False, report_only=False) + async def async_view(request): + return HttpResponse("OK") + + response = await async_view(HttpRequest()) + self.assertIsNone(getattr(response, "_csp_disabled", None)) + self.assertIsNone(getattr(response, "_csp_disabled_ro", None)) + + +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_csp_override_both(self): + @csp_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_config_ro, basic_config) + + async def test_csp_override_both_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.assertEqual(response._csp_config_ro, basic_config) + + def test_csp_override_enforced(self): + @csp_override(basic_config, report_only=False) + def sync_view(request): + return HttpResponse("OK") + + response = sync_view(HttpRequest()) + self.assertEqual(response._csp_config, basic_config) + self.assertIsNone(getattr(response, "_csp_config_ro", None)) + + async def test_csp_override_enforced_async_view(self): + @csp_override(basic_config, report_only=False) + async def async_view(request): + return HttpResponse("OK") + + response = await async_view(HttpRequest()) + self.assertEqual(response._csp_config, basic_config) + self.assertIsNone(getattr(response, "_csp_config_ro", None)) + + def test_csp_override_report_only(self): + @csp_override(basic_config, enforced=False) + def sync_view(request): + return HttpResponse("OK") + + response = sync_view(HttpRequest()) + self.assertEqual(response._csp_config_ro, basic_config) + self.assertIsNone(getattr(response, "_csp_config", None)) + + async def test_csp_override_report_only_async_view(self): + @csp_override(basic_config, enforced=False) + async def async_view(request): + return HttpResponse("OK") + + response = await async_view(HttpRequest()) + self.assertEqual(response._csp_config_ro, basic_config) + self.assertIsNone(getattr(response, "_csp_config", None)) + + def test_csp_override_neither(self): + @csp_override(basic_config, enforced=False, report_only=False) + def sync_view(request): + return HttpResponse("OK") + + response = sync_view(HttpRequest()) + self.assertIsNone(getattr(response, "_csp_config", None)) + self.assertIsNone(getattr(response, "_csp_config_ro", None)) + + async def test_csp_override_neither_async_view(self): + @csp_override(basic_config, enforced=False, report_only=False) + async def async_view(request): + return HttpResponse("OK") + + response = await async_view(HttpRequest()) + self.assertIsNone(getattr(response, "_csp_config", None)) + self.assertIsNone(getattr(response, "_csp_config_ro", None)) diff --git a/tests/middleware/test_csp.py b/tests/middleware/test_csp.py index c3321b76a59e..fc0241017729 100644 --- a/tests/middleware/test_csp.py +++ b/tests/middleware/test_csp.py @@ -105,6 +105,78 @@ def test_csp_500_debug_view(self): 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): + """ + Test the base state. + """ + 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_both(self): + """ + Test that `csp_disabled` will clear both headers. + """ + response = self.client.get("/csp-disabled/") + self.assertNotIn(CSP.HEADER_ENFORCE, response) + self.assertNotIn(CSP.HEADER_REPORT_ONLY, response) + + def test_csp_disabled_decorator(self): + """ + Test that `csp_disabled` will clear the enforced 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_disabled_report_only_decorator(self): + """ + Test that `csp_disabled` will clear 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_override_enforced_decorator(self): + """ + Test the `csp_override` decorator overrides the enforced settings. + """ + response = self.client.get("/override-csp-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_override_report_only_decorator(self): + """ + Test the `csp_override` decorator overrides the report-only settings. + """ + response = self.client.get("/override-csp-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): + """ + Test the `csp_override` decorator overrides both CSP Django settings. + """ + response = self.client.get("/override-csp-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 37120c7a544a..f73dac711e49 100644 --- a/tests/middleware/urls.py +++ b/tests/middleware/urls.py @@ -17,5 +17,11 @@ path("csp-report/", views.csp_report_view), path("csp-base/", views.empty_view), path("csp-nonce/", views.csp_nonce), + path("csp-disabled/", views.csp_disabled_both), + path("csp-disabled-enforced/", views.csp_disabled_enforced), + path("csp-disabled-report-only/", views.csp_disabled_ro), + path("override-csp-both/", views.override_csp_both), + path("override-csp-enforced/", views.override_csp_enforced), + path("override-csp-report-only/", views.override_csp_report_only), path("csp-500/", views.csp_500), ] diff --git a/tests/middleware/views.py b/tests/middleware/views.py index 6dc3ca24c7e9..cf475caf3061 100644 --- a/tests/middleware/views.py +++ b/tests/middleware/views.py @@ -3,9 +3,11 @@ 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_disabled, csp_override from django.views.decorators.csrf import csrf_exempt from django.views.generic import View @@ -29,6 +31,42 @@ def csp_nonce(request): return HttpResponse(get_nonce(request)) +@csp_disabled +def csp_disabled_both(request): + return HttpResponse() + + +@csp_disabled(report_only=False) +def csp_disabled_enforced(request): + return HttpResponse() + + +@csp_disabled(enforced=False) +def csp_disabled_ro(request): + return HttpResponse() + + +csp_policy_override = { + "default-src": [CSP.SELF], + "img-src": [CSP.SELF, "data:"], +} + + +@csp_override(csp_policy_override) +def override_csp_both(request): + return HttpResponse() + + +@csp_override(csp_policy_override, report_only=False) +def override_csp_enforced(request): + return HttpResponse() + + +@csp_override(csp_policy_override, enforced=False) +def override_csp_report_only(request): + return HttpResponse() + + def csp_500(request): try: raise Exception