Skip to content

Commit

Permalink
Add support for csp_clear decorator.
Browse files Browse the repository at this point in the history
This allows a directive to be cleared / unset entirely for a
particular view.

Closes mozilla#201
  • Loading branch information
tim-schilling committed Dec 21, 2023
1 parent a9f6bf6 commit f3ca7ae
Show file tree
Hide file tree
Showing 8 changed files with 79 additions and 6 deletions.
1 change: 1 addition & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -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
===
Expand Down
10 changes: 8 additions & 2 deletions csp/contrib/rate_limiting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', {})
Expand All @@ -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,
)
13 changes: 13 additions & 0 deletions csp/decorators.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
10 changes: 8 additions & 2 deletions csp/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
10 changes: 9 additions & 1 deletion csp/tests/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"])
12 changes: 12 additions & 0 deletions csp/tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('/')
Expand Down
7 changes: 6 additions & 1 deletion csp/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand All @@ -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 = {}
Expand Down
22 changes: 22 additions & 0 deletions docs/decorators.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
<configuration-chapter>` 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``
========

Expand Down

0 comments on commit f3ca7ae

Please sign in to comment.