Skip to content

Commit 550822b

Browse files
robhudsonnessita
andcommitted
Fixed #36532 -- Added Content Security Policy view decorators to override or disable policies.
Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>
1 parent 292b9e6 commit 550822b

10 files changed

Lines changed: 354 additions & 14 deletions

File tree

django/middleware/csp.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
from http import HTTPStatus
2-
31
from django.conf import settings
42
from django.utils.csp import CSP, LazyNonce, build_policy
53
from django.utils.deprecation import MiddlewareMixin
@@ -14,22 +12,21 @@ def process_request(self, request):
1412
request._csp_nonce = LazyNonce()
1513

1614
def process_response(self, request, response):
17-
# In DEBUG mode, exclude CSP headers for specific status codes that
18-
# trigger the debug view.
19-
exempted_status_codes = {
20-
HTTPStatus.NOT_FOUND,
21-
HTTPStatus.INTERNAL_SERVER_ERROR,
22-
}
23-
if settings.DEBUG and response.status_code in exempted_status_codes:
24-
return response
25-
2615
nonce = get_nonce(request)
16+
17+
sentinel = object()
18+
if (csp_config := getattr(response, "_csp_config", sentinel)) is sentinel:
19+
csp_config = settings.SECURE_CSP
20+
if (csp_ro_config := getattr(response, "_csp_ro_config", sentinel)) is sentinel:
21+
csp_ro_config = settings.SECURE_CSP_REPORT_ONLY
22+
2723
for header, config in [
28-
(CSP.HEADER_ENFORCE, settings.SECURE_CSP),
29-
(CSP.HEADER_REPORT_ONLY, settings.SECURE_CSP_REPORT_ONLY),
24+
(CSP.HEADER_ENFORCE, csp_config),
25+
(CSP.HEADER_REPORT_ONLY, csp_ro_config),
3026
]:
3127
# If headers are already set on the response, don't overwrite them.
3228
# This allows for views to set their own CSP headers as needed.
29+
# An empty config means CSP headers are not added to the response.
3330
if config and header not in response:
3431
response.headers[str(header)] = build_policy(config, nonce)
3532

django/views/debug.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from django.utils.module_loading import import_string
1919
from django.utils.regex_helper import _lazy_re_compile
2020
from django.utils.version import get_docs_version
21+
from django.views.decorators.csp import csp_override, csp_report_only_override
2122
from django.views.decorators.debug import coroutine_functions_to_sensitive_variables
2223

2324
# Minimal Django templates engine to render the error templates
@@ -59,6 +60,8 @@ def __repr__(self):
5960
return repr(self._wrapped)
6061

6162

63+
@csp_override({})
64+
@csp_report_only_override({})
6265
def technical_500_response(request, exc_type, exc_value, tb, status_code=500):
6366
"""
6467
Create a technical server error response. The last three arguments are
@@ -606,6 +609,8 @@ def get_exception_traceback_frames(self, exc_value, tb):
606609
tb = tb.tb_next
607610

608611

612+
@csp_override({})
613+
@csp_report_only_override({})
609614
def technical_404_response(request, exception):
610615
"""Create a technical 404 error response. `exception` is the Http404."""
611616
try:

django/views/decorators/csp.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from functools import wraps
2+
3+
from asgiref.sync import iscoroutinefunction
4+
5+
6+
def _make_csp_decorator(config_attr_name, config_attr_value):
7+
"""General CSP override decorator factory."""
8+
9+
if not isinstance(config_attr_value, dict):
10+
raise TypeError("CSP config should be a mapping.")
11+
12+
def decorator(view_func):
13+
@wraps(view_func)
14+
async def _wrapped_async_view(request, *args, **kwargs):
15+
response = await view_func(request, *args, **kwargs)
16+
setattr(response, config_attr_name, config_attr_value)
17+
return response
18+
19+
@wraps(view_func)
20+
def _wrapped_sync_view(request, *args, **kwargs):
21+
response = view_func(request, *args, **kwargs)
22+
setattr(response, config_attr_name, config_attr_value)
23+
return response
24+
25+
if iscoroutinefunction(view_func):
26+
return _wrapped_async_view
27+
return _wrapped_sync_view
28+
29+
return decorator
30+
31+
32+
def csp_override(config):
33+
"""Override the Content-Security-Policy header for a view."""
34+
return _make_csp_decorator("_csp_config", config)
35+
36+
37+
def csp_report_only_override(config):
38+
"""Override the Content-Security-Policy-Report-Only header for a view."""
39+
return _make_csp_decorator("_csp_ro_config", config)

docs/ref/csp.txt

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,92 @@ with the CSP specification.
154154
secure, random nonce that is generated for each request. See detailed
155155
explanation in :ref:`csp-nonce`.
156156

157+
Decorators
158+
==========
159+
160+
.. module:: django.views.decorators.csp
161+
162+
Django provides decorators to control the Content Security Policy headers on a
163+
per-view basis. These allow overriding or disabling the enforced or report-only
164+
policy for specific views, providing fine-grained control when the global
165+
settings are not sufficient. Applying these overrides fully replaces the base
166+
CSP: they do not merge with existing rules. They can be used alongside the
167+
constants defined in :class:`~django.utils.csp.CSP`.
168+
169+
.. warning::
170+
171+
Weakening or disabling a CSP policy on any page can compromise the security
172+
of the entire site. Because of the "same origin" policy, an attacker could
173+
exploit a vulnerability on one page to access other parts of the site.
174+
175+
.. function:: csp_override(config)(view)
176+
177+
Overrides the ``Content-Security-Policy`` header for the decorated view
178+
using directives in the same format as the :setting:`SECURE_CSP` setting.
179+
180+
The ``config`` argument must be a mapping with the desired CSP directives.
181+
If ``config`` is an empty mapping (``{}``), no CSP enforcement header will
182+
be added to the response returned by that view, effectively disabling CSP
183+
for that view.
184+
185+
Examples::
186+
187+
from django.http import HttpResponse
188+
from django.utils.csp import CSP
189+
from django.views.decorators.csp import csp_override
190+
191+
192+
@csp_override(
193+
{
194+
"default-src": [CSP.SELF],
195+
"img-src": [CSP.SELF, "data:"],
196+
}
197+
)
198+
def my_view(request):
199+
return HttpResponse("Custom Content-Security-Policy header applied")
200+
201+
202+
@csp_override({})
203+
def my_other_view(request):
204+
return HttpResponse("No Content-Security-Policy header added")
205+
206+
207+
.. function:: csp_report_only_override(config)(view)
208+
209+
Overrides the ``Content-Security-Policy-Report-Only`` header for the
210+
decorated view using directives in the same format as the
211+
:setting:`SECURE_CSP_REPORT_ONLY` setting.
212+
213+
Like :func:`csp_override`, the ``config`` argument must be a mapping with
214+
the desired CSP directives. If ``config`` is an empty mapping (``{}``), no
215+
CSP report-only header will be added to the response returned by that view,
216+
effectively disabling report-only CSP for that view.
217+
218+
Examples::
219+
220+
from django.http import HttpResponse
221+
from django.utils.csp import CSP
222+
from django.views.decorators.csp import csp_report_only_override
223+
224+
225+
@csp_report_only_override(
226+
{
227+
"default-src": [CSP.SELF],
228+
"img-src": [CSP.SELF, "data:"],
229+
"report-uri": "https://mysite.com/csp-report/",
230+
}
231+
)
232+
def my_view(request):
233+
return HttpResponse("Custom Content-Security-Policy-Report-Only header applied")
234+
235+
236+
@csp_report_only_override({})
237+
def my_other_view(request):
238+
return HttpResponse("No Content-Security-Policy-Report-Only header added")
239+
240+
The examples above assume function-based views. For class-based views, see the
241+
:ref:`guide for decorating class-based views <decorating-class-based-views>`.
242+
157243
.. _csp-nonce:
158244

159245
Nonce usage

docs/releases/6.0.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ The resulting ``Content-Security-Policy`` header would be set to:
7272

7373
To get started, follow the :doc:`CSP how-to guide </howto/csp>`. For in-depth
7474
guidance, see the :ref:`CSP security overview <security-csp>` and the
75-
:doc:`reference docs </ref/csp>`.
75+
:doc:`reference docs </ref/csp>`, which include details about decorators to
76+
override or disable policies on a per-view basis.
7677

7778
Adoption of Python's modern email API
7879
-------------------------------------

docs/topics/async.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ view functions:
8282
* :func:`~django.views.decorators.cache.cache_control`
8383
* :func:`~django.views.decorators.cache.never_cache`
8484
* :func:`~django.views.decorators.common.no_append_slash`
85+
* :func:`~django.views.decorators.csp.csp_override`
86+
* :func:`~django.views.decorators.csp.csp_report_only_override`
8587
* :func:`~django.views.decorators.csrf.csrf_exempt`
8688
* :func:`~django.views.decorators.csrf.csrf_protect`
8789
* :func:`~django.views.decorators.csrf.ensure_csrf_cookie`

tests/decorators/test_csp.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from itertools import product
2+
3+
from asgiref.sync import iscoroutinefunction
4+
5+
from django.http import HttpRequest, HttpResponse
6+
from django.test import SimpleTestCase
7+
from django.utils.csp import CSP
8+
from django.views.decorators.csp import csp_override, csp_report_only_override
9+
10+
basic_config = {
11+
"default-src": [CSP.SELF],
12+
}
13+
14+
15+
class CSPOverrideDecoratorTest(SimpleTestCase):
16+
def test_wrapped_sync_function_is_not_coroutine_function(self):
17+
def sync_view(request):
18+
return HttpResponse()
19+
20+
wrapped_view = csp_override({})(sync_view)
21+
self.assertIs(iscoroutinefunction(wrapped_view), False)
22+
23+
def test_wrapped_async_function_is_coroutine_function(self):
24+
async def async_view(request):
25+
return HttpResponse()
26+
27+
wrapped_view = csp_override({})(async_view)
28+
self.assertIs(iscoroutinefunction(wrapped_view), True)
29+
30+
def test_decorator_requires_mapping(self):
31+
for config, decorator in product(
32+
[None, 0, False, [], [1, 2, 3], 42, {4, 5}],
33+
(csp_override, csp_report_only_override),
34+
):
35+
with (
36+
self.subTest(config=config, decorator=decorator),
37+
self.assertRaisesMessage(TypeError, "CSP config should be a mapping"),
38+
):
39+
decorator(config)
40+
41+
def test_csp_override(self):
42+
@csp_override(basic_config)
43+
def sync_view(request):
44+
return HttpResponse("OK")
45+
46+
response = sync_view(HttpRequest())
47+
self.assertEqual(response._csp_config, basic_config)
48+
self.assertIs(hasattr(response, "_csp_ro_config"), False)
49+
50+
async def test_csp_override_async_view(self):
51+
@csp_override(basic_config)
52+
async def async_view(request):
53+
return HttpResponse("OK")
54+
55+
response = await async_view(HttpRequest())
56+
self.assertEqual(response._csp_config, basic_config)
57+
self.assertIs(hasattr(response, "_csp_ro_config"), False)
58+
59+
def test_csp_report_only_override(self):
60+
@csp_report_only_override(basic_config)
61+
def sync_view(request):
62+
return HttpResponse("OK")
63+
64+
response = sync_view(HttpRequest())
65+
self.assertEqual(response._csp_ro_config, basic_config)
66+
self.assertIs(hasattr(response, "_csp_config"), False)
67+
68+
async def test_csp_report_only_override_async_view(self):
69+
@csp_report_only_override(basic_config)
70+
async def async_view(request):
71+
return HttpResponse("OK")
72+
73+
response = await async_view(HttpRequest())
74+
self.assertEqual(response._csp_ro_config, basic_config)
75+
self.assertIs(hasattr(response, "_csp_config"), False)
76+
77+
def test_csp_override_both(self):
78+
@csp_override(basic_config)
79+
@csp_report_only_override(basic_config)
80+
def sync_view(request):
81+
return HttpResponse("OK")
82+
83+
response = sync_view(HttpRequest())
84+
self.assertEqual(response._csp_config, basic_config)
85+
self.assertEqual(response._csp_ro_config, basic_config)
86+
87+
async def test_csp_override_both_async_view(self):
88+
@csp_override(basic_config)
89+
@csp_report_only_override(basic_config)
90+
async def async_view(request):
91+
return HttpResponse("OK")
92+
93+
response = await async_view(HttpRequest())
94+
self.assertEqual(response._csp_config, basic_config)
95+
self.assertEqual(response._csp_ro_config, basic_config)

tests/middleware/test_csp.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,75 @@ def test_csp_500_debug_view(self):
106106
self.assertNotIn(CSP.HEADER_REPORT_ONLY, response)
107107

108108

109+
@override_settings(
110+
MIDDLEWARE=["django.middleware.csp.ContentSecurityPolicyMiddleware"],
111+
ROOT_URLCONF="middleware.urls",
112+
SECURE_CSP=basic_config,
113+
SECURE_CSP_REPORT_ONLY=basic_config,
114+
)
115+
class CSPMiddlewareWithDecoratedViewsTest(SimpleTestCase):
116+
def test_no_decorators(self):
117+
response = self.client.get("/csp-base/")
118+
self.assertEqual(response[CSP.HEADER_ENFORCE], basic_policy)
119+
self.assertEqual(response[CSP.HEADER_REPORT_ONLY], basic_policy)
120+
121+
def test_csp_disabled_enforced(self):
122+
"""
123+
`csp_override({})` only disables the enforced CSP header.
124+
"""
125+
response = self.client.get("/csp-disabled-enforced/")
126+
self.assertNotIn(CSP.HEADER_ENFORCE, response)
127+
self.assertEqual(response[CSP.HEADER_REPORT_ONLY], basic_policy)
128+
129+
def test_csp_report_only_disabled(self):
130+
"""
131+
`csp_report_only_override({})` only disables the report-only header.
132+
"""
133+
response = self.client.get("/csp-disabled-report-only/")
134+
self.assertNotIn(CSP.HEADER_REPORT_ONLY, response)
135+
self.assertEqual(response[CSP.HEADER_ENFORCE], basic_policy)
136+
137+
def test_csp_disabled_both(self):
138+
"""
139+
Using both CSP decorators with empty mappings will clear both headers.
140+
"""
141+
response = self.client.get("/csp-disabled-both/")
142+
self.assertNotIn(CSP.HEADER_ENFORCE, response)
143+
self.assertNotIn(CSP.HEADER_REPORT_ONLY, response)
144+
145+
def test_csp_override_enforced(self):
146+
"""
147+
`csp_override` only overrides the enforced header.
148+
"""
149+
response = self.client.get("/csp-override-enforced/")
150+
self.assertEqual(
151+
response[CSP.HEADER_ENFORCE], "default-src 'self'; img-src 'self' data:"
152+
)
153+
self.assertEqual(response[CSP.HEADER_REPORT_ONLY], basic_policy)
154+
155+
def test_csp_report_only_override(self):
156+
"""
157+
`csp_report_only_override` only overrides the report-only header.
158+
"""
159+
response = self.client.get("/csp-override-report-only/")
160+
self.assertEqual(
161+
response[CSP.HEADER_REPORT_ONLY], "default-src 'self'; img-src 'self' data:"
162+
)
163+
self.assertEqual(response[CSP.HEADER_ENFORCE], basic_policy)
164+
165+
def test_csp_override_both_decorator(self):
166+
"""
167+
Using both CSP decorators overrides both CSP Django settings.
168+
"""
169+
response = self.client.get("/csp-override-both/")
170+
self.assertEqual(
171+
response[CSP.HEADER_ENFORCE], "default-src 'self'; img-src 'self' data:"
172+
)
173+
self.assertEqual(
174+
response[CSP.HEADER_REPORT_ONLY], "default-src 'self'; img-src 'self' data:"
175+
)
176+
177+
109178
@override_settings(
110179
ROOT_URLCONF="middleware.urls",
111180
SECURE_CSP_REPORT_ONLY={

0 commit comments

Comments
 (0)