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:
parent
292b9e6fe8
commit
550822bcee
@ -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)
|
||||
|
||||
|
@ -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:
|
||||
|
39
django/views/decorators/csp.py
Normal file
39
django/views/decorators/csp.py
Normal 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)
|
@ -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
|
||||
|
@ -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
|
||||
-------------------------------------
|
||||
|
@ -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`
|
||||
|
95
tests/decorators/test_csp.py
Normal file
95
tests/decorators/test_csp.py
Normal 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)
|
@ -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={
|
||||
|
@ -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),
|
||||
]
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user