From f3ca7ae787719fe2c0bb8074a7d8e52aaeee1a76 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Thu, 21 Dec 2023 15:05:43 -0600 Subject: [PATCH] Add support for csp_clear decorator. This allows a directive to be cleared / unset entirely for a particular view. Closes #201 --- CHANGES | 1 + csp/contrib/rate_limiting.py | 10 ++++++++-- csp/decorators.py | 13 +++++++++++++ csp/middleware.py | 10 ++++++++-- csp/tests/test_decorators.py | 10 +++++++++- csp/tests/test_middleware.py | 12 ++++++++++++ csp/utils.py | 7 ++++++- docs/decorators.rst | 22 ++++++++++++++++++++++ 8 files changed, 79 insertions(+), 6 deletions(-) diff --git a/CHANGES b/CHANGES index 90b5406..c8844e6 100644 --- a/CHANGES +++ b/CHANGES @@ -10,6 +10,7 @@ Next - Drop support for EOL Python <3.6 and Django <2.2 versions - Rename default branch to main - Fix capturing brackets in script template tags +- Add support for csp_clear decorator to clear directives per view (#201) 3.7 === diff --git a/csp/contrib/rate_limiting.py b/csp/contrib/rate_limiting.py index 8a4d087..10c6473 100644 --- a/csp/contrib/rate_limiting.py +++ b/csp/contrib/rate_limiting.py @@ -11,6 +11,7 @@ class RateLimitedCSPMiddleware(CSPMiddleware): to report-uri by excluding it from some requests.""" def build_policy(self, request, response): + clear = getattr(response, '_csp_clear', None) config = getattr(response, '_csp_config', None) update = getattr(response, '_csp_update', None) replace = getattr(response, '_csp_replace', {}) @@ -21,5 +22,10 @@ def build_policy(self, request, response): if not include_report_uri: replace['report-uri'] = None - return build_policy(config=config, update=update, replace=replace, - nonce=nonce) + return build_policy( + clear=clear, + config=config, + update=update, + replace=replace, + nonce=nonce, + ) diff --git a/csp/decorators.py b/csp/decorators.py index bce3352..c25ef3a 100644 --- a/csp/decorators.py +++ b/csp/decorators.py @@ -1,6 +1,19 @@ from functools import wraps +def csp_clear(*args): + clear = set(value.lower().replace('_', '-') for value in args) + + def decorator(f): + @wraps(f) + def _wrapped(*a, **kw): + r = f(*a, **kw) + r._csp_clear = clear + return r + return _wrapped + return decorator + + def csp_exempt(f): @wraps(f) def _wrapped(*a, **kw): diff --git a/csp/middleware.py b/csp/middleware.py index 73397e1..6118131 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -81,9 +81,15 @@ def process_response(self, request, response): return response def build_policy(self, request, response): + clear = getattr(response, '_csp_clear', None) config = getattr(response, '_csp_config', None) update = getattr(response, '_csp_update', None) replace = getattr(response, '_csp_replace', None) nonce = getattr(request, '_csp_nonce', None) - return build_policy(config=config, update=update, replace=replace, - nonce=nonce) + return build_policy( + clear=clear, + config=config, + update=update, + replace=replace, + nonce=nonce, + ) diff --git a/csp/tests/test_decorators.py b/csp/tests/test_decorators.py index a4bd733..c6a6ed5 100644 --- a/csp/tests/test_decorators.py +++ b/csp/tests/test_decorators.py @@ -2,7 +2,7 @@ from django.test import RequestFactory from django.test.utils import override_settings -from csp.decorators import csp, csp_replace, csp_update, csp_exempt +from csp.decorators import csp, csp_replace, csp_update, csp_exempt, csp_clear from csp.middleware import CSPMiddleware from csp.tests.utils import response @@ -110,3 +110,11 @@ def view_with_decorator(request): mw.process_response(REQUEST, response) policy_list = sorted(response['Content-Security-Policy'].split("; ")) assert policy_list == ["font-src bar.com", "img-src foo.com"] + + +def test_csp_clear(): + @csp_clear("IMG_SRC", "frame-ancestors") + def view(request): + return HttpResponse() + response = view(REQUEST) + assert response._csp_clear == set(["img-src", "frame-ancestors"]) diff --git a/csp/tests/test_middleware.py b/csp/tests/test_middleware.py index ce06b24..3ed9306 100644 --- a/csp/tests/test_middleware.py +++ b/csp/tests/test_middleware.py @@ -71,6 +71,18 @@ def test_use_update(): assert response[HEADER] == "default-src 'self' example.com" +def test_use_clear(): + request = rf.get('/') + response = HttpResponse() + response._csp_update = { + 'default-src': ['example.com'], + 'frame-ancestors': ['example.com'], + } + response._csp_clear = set(["frame-ancestors"]) + mw.process_response(request, response) + assert response[HEADER] == "default-src 'self' example.com" + + @override_settings(CSP_IMG_SRC=['foo.com']) def test_use_replace(): request = rf.get('/') diff --git a/csp/utils.py b/csp/utils.py index 35a73be..08aa728 100644 --- a/csp/utils.py +++ b/csp/utils.py @@ -53,13 +53,15 @@ def from_settings(): } -def build_policy(config=None, update=None, replace=None, nonce=None): +def build_policy( + clear=None, config=None, update=None, replace=None, nonce=None): """Builds the policy as a string from the settings.""" if config is None: config = from_settings() # Be careful, don't mutate config as it could be from settings + clear = clear if clear is not None else set() update = update if update is not None else {} replace = replace if replace is not None else {} csp = {} @@ -84,6 +86,9 @@ def build_policy(config=None, update=None, replace=None, nonce=None): else: csp[k] += tuple(v) + for value in clear: + csp.pop(value, None) + report_uri = csp.pop('report-uri', None) policy_parts = {} diff --git a/docs/decorators.rst b/docs/decorators.rst index 3ba6183..b38cbc1 100644 --- a/docs/decorators.rst +++ b/docs/decorators.rst @@ -82,6 +82,28 @@ The arguments and values are the same as ``@csp_update``:: return render(...) +``@csp_clear`` +================ + +The ``@csp_clear`` decorator allows you to **clear** a CSP directive. Clearing +a directive will take priority over any of the other options. + +The values to the decorator are the same as the :ref:`settings +` without the ``CSP_`` prefix, e.g. ``IMG_SRC``. +(They are also case-insensitive.):: + + from csp.decorators import csp_clear + + # By default only allow responses to be served in an iframe + # from imgsrv.com + # settings.CSP_FRAME_ANCESTORS = ['imgsrv.com'] + # Will allow this particular view to be served in an iframe from any\ + # domain, including imgsrv.com. + @csp_clear('frame-ancestors') + def myview(request): + return render(...) + + ``@csp`` ========