1
0
mirror of https://github.com/django/django.git synced 2025-09-09 18:49:11 +00:00

Fixed #36532 -- Added Content Security Policy view decorators to override or disable policies.

Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>
This commit is contained in:
Rob Hudson 2025-08-23 12:23:53 -07:00 committed by nessita
parent 292b9e6fe8
commit 550822bcee
10 changed files with 354 additions and 14 deletions

View File

@ -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)

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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
-------------------------------------

View File

@ -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`

View File

@ -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)

View File

@ -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={

View File

@ -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),
]

View File

@ -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