diff --git a/CHANGES b/CHANGES index 90b5406..f238988 100644 --- a/CHANGES +++ b/CHANGES @@ -10,6 +10,14 @@ 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 multiple content security policies +- Deprecate single policy settings. Policies are now + configured through two settings: CSP_POLICIES and CSP_POLICY_DEFINITIONS. +- Fix CSP_EXCLUDE_URL_PREFIXES: previously this would error unless it was a + single string. NOTE: Like other settings, CSP_EXCLUDE_URL_PREFIXES is now + configured per policy in CSP_POLICY_DEFINITIONS under the key + exclude_url_prefixes. +- The block-all-mixed-content directive has been deprecated as it's now obsolete. 3.7 === diff --git a/csp/conf/__init__.py b/csp/conf/__init__.py new file mode 100644 index 0000000..c407682 --- /dev/null +++ b/csp/conf/__init__.py @@ -0,0 +1,57 @@ +__all__ = [ + 'defaults', + 'deprecation', + 'directive_to_setting', + 'get_declared_policies', + 'get_declared_policy_definitions', + 'setting_to_directive', + 'DIRECTIVES', +] + +from django.conf import settings + +from . import defaults +from .deprecation import ( + directive_to_setting, + setting_to_directive, + _handle_legacy_settings, +) + + +DIRECTIVES = defaults.DIRECTIVES +PSEUDO_DIRECTIVES = defaults.PSEUDO_DIRECTIVES + + +def _csp_definitions_update(csp_definitions, other): + """ Update one csp definitions dictionary with another """ + if isinstance(other, dict): + other = other.items() + for name, csp in other: + csp_definitions.setdefault(name, {}).update(csp) + return csp_definitions + + +def get_declared_policy_definitions(): + custom_definitions = _csp_definitions_update( + {}, + getattr( + settings, + 'CSP_POLICY_DEFINITIONS', + {'default': {}}, + ), + ) + _handle_legacy_settings( + custom_definitions['default'], + allow_legacy=not hasattr(settings, 'CSP_POLICY_DEFINITIONS'), + ) + definitions = _csp_definitions_update( + {}, + {name: defaults.POLICY for name in custom_definitions} + ) + for name, csp in custom_definitions.items(): + definitions.setdefault(name, {}).update(csp) + return definitions + + +def get_declared_policies(): + return getattr(settings, 'CSP_POLICIES', defaults.POLICIES) diff --git a/csp/conf/defaults.py b/csp/conf/defaults.py new file mode 100644 index 0000000..b918ecb --- /dev/null +++ b/csp/conf/defaults.py @@ -0,0 +1,47 @@ +POLICIES = ('default',) + +POLICY = { + # Fetch Directives + 'child-src': None, + 'connect-src': None, + 'default-src': ("'self'",), + 'script-src': None, + 'script-src-attr': None, + 'script-src-elem': None, + 'object-src': None, + 'style-src': None, + 'style-src-attr': None, + 'style-src-elem': None, + 'font-src': None, + 'frame-src': None, + 'img-src': None, + 'manifest-src': None, + 'media-src': None, + 'prefetch-src': None, + 'worker-src': None, + # Document Directives + 'base-uri': None, + 'plugin-types': None, + 'sandbox': None, + # Navigation Directives + 'form-action': None, + 'frame-ancestors': None, + 'navigate-to': None, + # Reporting Directives + 'report-uri': None, + 'report-to': None, + 'require-sri-for': None, + # Trusted Types Directives + 'require-trusted-types-for': None, + 'trusted-types': None, + # Other Directives + 'upgrade-insecure-requests': False, + 'block-all-mixed-content': False, # Obsolete + # Pseudo Directives + 'report_only': False, + 'include_nonce_in': ('default-src',), + 'exclude_url_prefixes': (), +} + +DIRECTIVES = set(POLICY) +PSEUDO_DIRECTIVES = {d for d in DIRECTIVES if '_' in d} diff --git a/csp/conf/deprecation.py b/csp/conf/deprecation.py new file mode 100644 index 0000000..2e9d539 --- /dev/null +++ b/csp/conf/deprecation.py @@ -0,0 +1,70 @@ +import warnings + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +from . import defaults + + +BLOCK_ALL_MIXED_CONTENT_DEPRECATION_WARNING = ( + "block-all-mixed-content is obsolete. " + "All mixed content is now blocked if it can't be autoupgraded." +) + +LEGACY_SETTINGS_NAMES_DEPRECATION_WARNING = ( + 'The following settings are deprecated: %s. ' + 'Use CSP_POLICY_DEFINITIONS and CSP_POLICIES instead.' +) + + +def setting_to_directive(setting, value, prefix='CSP_'): + setting = setting[len(prefix):].lower() + if setting not in defaults.PSEUDO_DIRECTIVES: + setting = setting.replace('_', '-') + assert setting in defaults.DIRECTIVES + if isinstance(value, str): + value = [value] + return setting, value + + +def directive_to_setting(directive, prefix='CSP_'): + setting = '{}{}'.format( + prefix, + directive.replace('-', '_').upper() + ) + return setting + + +_LEGACY_SETTINGS = { + directive_to_setting(directive) for directive in defaults.DIRECTIVES +} + + +def _handle_legacy_settings(csp, allow_legacy): + """ + Custom defaults allow you to set values for csp directives + that will apply to all CSPs defined in CSP_DEFINITIONS, avoiding + repetition and allowing custom default values. + """ + legacy_names = ( + _LEGACY_SETTINGS + & set(s for s in dir(settings) if s.startswith('CSP_')) + ) + if not legacy_names: + return + + if not allow_legacy: + raise ImproperlyConfigured( + "Setting CSP_POLICY_DEFINITIONS is not allowed with the following " + "deprecated settings: %s" % ", ".join(legacy_names) + ) + + warnings.warn( + LEGACY_SETTINGS_NAMES_DEPRECATION_WARNING % ', '.join(legacy_names), + DeprecationWarning, + ) + legacy_csp = ( + setting_to_directive(name, value=getattr(settings, name)) + for name in legacy_names if name not in csp + ) + csp.update(legacy_csp) diff --git a/csp/contrib/rate_limiting.py b/csp/contrib/rate_limiting.py index 8a4d087..c479bb3 100644 --- a/csp/contrib/rate_limiting.py +++ b/csp/contrib/rate_limiting.py @@ -3,23 +3,20 @@ from django.conf import settings from csp.middleware import CSPMiddleware -from csp.utils import build_policy class RateLimitedCSPMiddleware(CSPMiddleware): """A CSP middleware that rate-limits the number of violation reports sent to report-uri by excluding it from some requests.""" - def build_policy(self, request, response): - config = getattr(response, '_csp_config', None) - update = getattr(response, '_csp_update', None) - replace = getattr(response, '_csp_replace', {}) - nonce = getattr(request, '_csp_nonce', None) + def get_build_kwargs(self, request, response): + build_kwargs = super().get_build_kwargs(request, response) + replace = build_kwargs['replace'] or {} report_percentage = getattr(settings, 'CSP_REPORT_PERCENTAGE') include_report_uri = random.random() < report_percentage if not include_report_uri: replace['report-uri'] = None + build_kwargs['replace'] = replace - return build_policy(config=config, update=update, replace=replace, - nonce=nonce) + return build_kwargs diff --git a/csp/decorators.py b/csp/decorators.py index bce3352..3835f48 100644 --- a/csp/decorators.py +++ b/csp/decorators.py @@ -1,4 +1,11 @@ from functools import wraps +from itertools import chain + +from .utils import ( + get_declared_policies, + _policies_from_args_and_kwargs, + _policies_from_names_and_kwargs, +) def csp_exempt(f): @@ -10,8 +17,25 @@ def _wrapped(*a, **kw): return _wrapped -def csp_update(**kwargs): - update = dict((k.lower().replace('_', '-'), v) for k, v in kwargs.items()) +def csp_select(*names): + """ + Trim or add additional named policies. + """ + def decorator(f): + @wraps(f) + def _wrapped(*a, **kw): + r = f(*a, **kw) + r._csp_select = names + return r + return _wrapped + return decorator + + +def csp_update(csp_names=('default',), **kwargs): + update = _policies_from_names_and_kwargs( + csp_names, + kwargs, + ) def decorator(f): @wraps(f) @@ -23,8 +47,11 @@ def _wrapped(*a, **kw): return decorator -def csp_replace(**kwargs): - replace = dict((k.lower().replace('_', '-'), v) for k, v in kwargs.items()) +def csp_replace(csp_names=('default',), **kwargs): + replace = _policies_from_names_and_kwargs( + csp_names, + kwargs, + ) def decorator(f): @wraps(f) @@ -36,12 +63,43 @@ def _wrapped(*a, **kw): return decorator -def csp(**kwargs): - config = dict( - (k.lower().replace('_', '-'), [v] if isinstance(v, str) else v) - for k, v - in kwargs.items() - ) +def csp_append(*args, **kwargs): + append = _policies_from_args_and_kwargs(args, kwargs) + + def decorator(f): + @wraps(f) + def _wrapped(*a, **kw): + r = f(*a, **kw) + # TODO: these decorators would interact more smoothly and + # be more performant if we recorded the result on the function. + if hasattr(r, "_csp_config"): + r._csp_config.update({ + name: policy for name, policy in append.items() + if name not in r._csp_config + }) + select = getattr(r, "_csp_select", None) + if select: + select = list(select) + r._csp_select = tuple(chain( + select, + (name for name in append if name not in select), + )) + else: + r._csp_config = append + select = getattr(r, "_csp_select", None) + if not select: + select = get_declared_policies() + r._csp_select = tuple(chain( + select, + (name for name in append if name not in select), + )) + return r + return _wrapped + return decorator + + +def csp(*args, **kwargs): + config = _policies_from_args_and_kwargs(args, kwargs) def decorator(f): @wraps(f) diff --git a/csp/middleware.py b/csp/middleware.py index 73397e1..1f36078 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -2,28 +2,16 @@ import os import base64 +from collections import defaultdict from functools import partial from django.conf import settings +from django.utils.deprecation import MiddlewareMixin from django.utils.functional import SimpleLazyObject -try: - from django.utils.six.moves import http_client -except ImportError: - # django 3.x removed six - import http.client as http_client - -try: - from django.utils.deprecation import MiddlewareMixin -except ImportError: - class MiddlewareMixin(object): - """ - If this middleware doesn't exist, this is an older version of django - and we don't need it. - """ - pass - -from csp.utils import build_policy +from .utils import ( + build_policy, EXEMPTED_DEBUG_CODES, HTTP_HEADERS, +) class CSPMiddleware(MiddlewareMixin): @@ -54,36 +42,48 @@ def process_response(self, request, response): if getattr(response, '_csp_exempt', False): return response - # Check for ignored path prefix. - prefixes = getattr(settings, 'CSP_EXCLUDE_URL_PREFIXES', ()) - if request.path_info.startswith(prefixes): - return response - # Check for debug view - status_code = response.status_code - exempted_debug_codes = ( - http_client.INTERNAL_SERVER_ERROR, - http_client.NOT_FOUND, - ) - if status_code in exempted_debug_codes and settings.DEBUG: + if response.status_code in EXEMPTED_DEBUG_CODES and settings.DEBUG: return response - header = 'Content-Security-Policy' - if getattr(settings, 'CSP_REPORT_ONLY', False): - header += '-Report-Only' - - if header in response: + existing_headers = { + header for header in HTTP_HEADERS if header in response + } + if len(existing_headers) == len(HTTP_HEADERS): # Don't overwrite existing headers. return response - response[header] = self.build_policy(request, response) - + headers = defaultdict(list) + path_info = request.path_info + + for csp, report_only, exclude_prefixes in self.build_policy( + request, response, + ): + # Check for ignored path prefix. + for prefix in exclude_prefixes: + if path_info.startswith(prefix): + break + else: + header = HTTP_HEADERS[int(report_only)] + if header in existing_headers: # don't overwrite + continue + headers[header].append(csp) + + for header, policies in headers.items(): + # Multiple policies are joined by a comma and should be treated by + # the browser as though they were delivered under multiple headers. + response[header] = ', '.join(policies) return response + def get_build_kwargs(self, request, response): + build_kwargs = { + key: getattr(response, '_csp_%s' % key, None) + for key in ('config', 'update', 'replace', 'select') + } + build_kwargs["nonce"] = getattr(request, '_csp_nonce', None) + return build_kwargs + def build_policy(self, request, response): - 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( + **self.get_build_kwargs(request, response), + ) diff --git a/csp/tests/settings.py b/csp/tests/settings.py index dc74ff1..1f14de1 100644 --- a/csp/tests/settings.py +++ b/csp/tests/settings.py @@ -1,9 +1,16 @@ import django -CSP_REPORT_ONLY = False - -CSP_INCLUDE_NONCE_IN = ['default-src'] +CSP_POLICY_DEFINITIONS = { + 'default': { + 'default-src': ("'self'",), + 'report_only': False, + 'include_nonce_in': ('default-src',), + }, + 'report': { + 'report_only': True, + }, +} DATABASES = { 'default': { diff --git a/csp/tests/test_contrib.py b/csp/tests/test_contrib.py index 98ccded..403a731 100644 --- a/csp/tests/test_contrib.py +++ b/csp/tests/test_contrib.py @@ -1,9 +1,8 @@ from django.http import HttpResponse from django.test import RequestFactory -from django.test.utils import override_settings from csp.contrib.rate_limiting import RateLimitedCSPMiddleware -from csp.tests.utils import response +from csp.tests.utils import override_legacy_settings, response HEADER = 'Content-Security-Policy' @@ -11,7 +10,7 @@ rf = RequestFactory() -@override_settings(CSP_REPORT_PERCENTAGE=0.1, CSP_REPORT_URI='x') +@override_legacy_settings(CSP_REPORT_PERCENTAGE=0.1, CSP_REPORT_URI='x') def test_report_percentage(): times_seen = 0 for _ in range(5000): diff --git a/csp/tests/test_decorators.py b/csp/tests/test_decorators.py index a4bd733..4b5dcb3 100644 --- a/csp/tests/test_decorators.py +++ b/csp/tests/test_decorators.py @@ -2,11 +2,15 @@ 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_append, csp_replace, csp_select, csp_update, csp_exempt, +) from csp.middleware import CSPMiddleware -from csp.tests.utils import response +from csp.tests.utils import override_legacy_settings, response +from csp.utils import policy_names, HTTP_HEADERS +HEADER, REPORT_ONLY_HEADER = HTTP_HEADERS REQUEST = RequestFactory().get('/') mw = CSPMiddleware(response()) @@ -19,51 +23,95 @@ def view(request): assert response._csp_exempt -@override_settings(CSP_IMG_SRC=['foo.com']) +@override_settings(CSP_POLICIES=("default", "report")) +def test_csp_select(): + def view_without_decorator(request): + return HttpResponse() + response = view_without_decorator(REQUEST) + mw.process_response(REQUEST, response) + assert response[HEADER] == "default-src 'self'" + assert response[REPORT_ONLY_HEADER] == "default-src 'self'" + + @csp_select('default') + def view_with_decorator(request): + return HttpResponse() + response = view_with_decorator(REQUEST) + assert response._csp_select == ('default',) + mw.process_response(REQUEST, response) + assert response[HEADER] == "default-src 'self'" + assert REPORT_ONLY_HEADER not in response + + response = view_without_decorator(REQUEST) + mw.process_response(REQUEST, response) + assert response[HEADER] == "default-src 'self'" + assert response[REPORT_ONLY_HEADER] == "default-src 'self'" + + +@override_legacy_settings(CSP_IMG_SRC=['foo.com']) def test_csp_update(): def view_without_decorator(request): return HttpResponse() response = view_without_decorator(REQUEST) mw.process_response(REQUEST, response) - policy_list = sorted(response['Content-Security-Policy'].split("; ")) + policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["default-src 'self'", "img-src foo.com"] @csp_update(IMG_SRC='bar.com') def view_with_decorator(request): return HttpResponse() response = view_with_decorator(REQUEST) - assert response._csp_update == {'img-src': 'bar.com'} + assert response._csp_update == {'default': {'img-src': ['bar.com']}} mw.process_response(REQUEST, response) - policy_list = sorted(response['Content-Security-Policy'].split("; ")) + policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["default-src 'self'", "img-src foo.com bar.com"] response = view_without_decorator(REQUEST) mw.process_response(REQUEST, response) - policy_list = sorted(response['Content-Security-Policy'].split("; ")) + policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["default-src 'self'", "img-src foo.com"] -@override_settings(CSP_IMG_SRC=['foo.com']) +@override_settings(CSP_POLICIES=('default', 'report')) +def test_csp_update_multiple(): + @csp_update( + default={'img-src': 'bar.com'}, + report={'font-src': 'foo.com'}, + ) + def view_with_decorator(request): + return HttpResponse() + response = view_with_decorator(REQUEST) + assert response._csp_update == { + 'default': {'img-src': 'bar.com'}, + 'report': {'font-src': 'foo.com'}, + } + mw.process_response(REQUEST, response) + policy_list = sorted(response[HEADER].split("; ")) + assert policy_list == ["default-src 'self'", "img-src bar.com"] + policy_list = sorted(response[REPORT_ONLY_HEADER].split("; ")) + assert policy_list == ["default-src 'self'", "font-src foo.com"] + + +@override_legacy_settings(CSP_IMG_SRC=['foo.com']) def test_csp_replace(): def view_without_decorator(request): return HttpResponse() response = view_without_decorator(REQUEST) mw.process_response(REQUEST, response) - policy_list = sorted(response['Content-Security-Policy'].split("; ")) + policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["default-src 'self'", "img-src foo.com"] @csp_replace(IMG_SRC='bar.com') def view_with_decorator(request): return HttpResponse() response = view_with_decorator(REQUEST) - assert response._csp_replace == {'img-src': 'bar.com'} + assert response._csp_replace == {'default': {'img-src': ['bar.com']}} mw.process_response(REQUEST, response) - policy_list = sorted(response['Content-Security-Policy'].split("; ")) + policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["default-src 'self'", "img-src bar.com"] response = view_without_decorator(REQUEST) mw.process_response(REQUEST, response) - policy_list = sorted(response['Content-Security-Policy'].split("; ")) + policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["default-src 'self'", "img-src foo.com"] @csp_replace(IMG_SRC=None) @@ -71,42 +119,234 @@ def view_removing_directive(request): return HttpResponse() response = view_removing_directive(REQUEST) mw.process_response(REQUEST, response) - policy_list = sorted(response["Content-Security-Policy"].split("; ")) + policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["default-src 'self'"] +@override_settings(CSP_POLICIES=('default', 'report')) +def test_csp_replace_multiple(): + @csp_replace( + default={'img-src': 'bar.com', 'default-src': None}, + report={'font-src': 'foo.com'}, + ) + def view_with_decorator(request): + return HttpResponse() + response = view_with_decorator(REQUEST) + assert response._csp_replace == { + 'default': {'img-src': 'bar.com', 'default-src': None}, + 'report': {'font-src': 'foo.com'}, + } + mw.process_response(REQUEST, response) + assert response[HEADER] == "img-src bar.com" + policy_list = sorted(response[REPORT_ONLY_HEADER].split("; ")) + assert policy_list == ["default-src 'self'", "font-src foo.com"] + + def test_csp(): def view_without_decorator(request): return HttpResponse() response = view_without_decorator(REQUEST) mw.process_response(REQUEST, response) - policy_list = sorted(response['Content-Security-Policy'].split("; ")) + policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["default-src 'self'"] @csp(IMG_SRC=['foo.com'], FONT_SRC=['bar.com']) def view_with_decorator(request): return HttpResponse() response = view_with_decorator(REQUEST) - assert response._csp_config == \ - {'img-src': ['foo.com'], 'font-src': ['bar.com']} + assert response._csp_config == { + policy_names.last_policy_name: { + 'img-src': ['foo.com'], + 'font-src': ['bar.com'], + } + } mw.process_response(REQUEST, response) - policy_list = sorted(response['Content-Security-Policy'].split("; ")) + policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["font-src bar.com", "img-src foo.com"] response = view_without_decorator(REQUEST) mw.process_response(REQUEST, response) - policy_list = sorted(response['Content-Security-Policy'].split("; ")) + policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["default-src 'self'"] +def test_csp_case_insensitive(): + @csp(img_src=['foo.com'], font_src=['bar.com']) + def view_with_decorator(request): + return HttpResponse() + response = view_with_decorator(REQUEST) + assert response._csp_config == { + policy_names.last_policy_name: { + 'img-src': ['foo.com'], + 'font-src': ['bar.com'], + } + } + mw.process_response(REQUEST, response) + policy_list = sorted(response[HEADER].split("; ")) + assert policy_list == ["font-src bar.com", "img-src foo.com"] + + +def test_csp_with_args(): + @csp( + {'img-src': ['foo.com']}, + {'font-src': ['bar.com'], 'report_only': True}, + ) + def view_with_decorator(request): + return HttpResponse() + response = view_with_decorator(REQUEST) + assert sorted(list(d.items()) for d in response._csp_config.values()) == [ + [('font-src', ['bar.com']), ('report_only', True)], + [('img-src', ['foo.com'])], + ] + mw.process_response(REQUEST, response) + assert response[HEADER] == "img-src foo.com" + assert response[REPORT_ONLY_HEADER] == "font-src bar.com" + + +def test_csp_and_csp_select(): + @csp(new_policy={'font-src': ['bar.com']}) + @csp_select('report') # csp doesn't override csp_select + def view_with_decorator(request): + return HttpResponse() + + response = view_with_decorator(REQUEST) + assert response._csp_config == { + 'new_policy': { + 'font-src': ['bar.com'], + }, + } + assert response._csp_select == ('report',) + mw.process_response(REQUEST, response) + assert response[REPORT_ONLY_HEADER] == "default-src 'self'" + assert HEADER not in response + + view_with_decorator = csp_select('default')(view_with_decorator) + response = view_with_decorator(REQUEST) + assert response._csp_select == ('default',) + mw.process_response(REQUEST, response) + assert response[HEADER] == "default-src 'self'" + assert REPORT_ONLY_HEADER not in response + + view_with_decorator = csp_select('new_policy')(view_with_decorator) + response = view_with_decorator(REQUEST) + mw.process_response(REQUEST, response) + assert response._csp_select == ('new_policy',) + assert response[HEADER] == "font-src bar.com" + assert REPORT_ONLY_HEADER not in response + + view_with_decorator = csp_select('new_policy', 'default')( + view_with_decorator, + ) + response = view_with_decorator(REQUEST) + mw.process_response(REQUEST, response) + assert response._csp_select == ('new_policy', 'default') + assert response[HEADER] == "font-src bar.com, default-src 'self'" + assert REPORT_ONLY_HEADER not in response + + def test_csp_string_values(): # Test backwards compatibility where values were strings @csp(IMG_SRC='foo.com', FONT_SRC='bar.com') def view_with_decorator(request): return HttpResponse() response = view_with_decorator(REQUEST) - assert response._csp_config == \ - {'img-src': ['foo.com'], 'font-src': ['bar.com']} + assert response._csp_config == { + policy_names.last_policy_name: { + 'img-src': ['foo.com'], + 'font-src': ['bar.com'], + } + } mw.process_response(REQUEST, response) - policy_list = sorted(response['Content-Security-Policy'].split("; ")) + policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["font-src bar.com", "img-src foo.com"] + + +@override_settings(CSP_POLICIES=("report",)) +def test_csp_append(): + def view_without_decorator(request): + return HttpResponse() + response = view_without_decorator(REQUEST) + mw.process_response(REQUEST, response) + assert response[REPORT_ONLY_HEADER] == "default-src 'self'" + assert HEADER not in response + + @csp_append(FONT_SRC=['bar.com']) + def view_with_decorator(request): + return HttpResponse() + response = view_with_decorator(REQUEST) + assert response._csp_config == { + policy_names.last_policy_name: { + 'font-src': ['bar.com'], + } + } + assert response._csp_select == ("report", policy_names.last_policy_name) + mw.process_response(REQUEST, response) + assert response[HEADER] == "font-src bar.com" + assert response[REPORT_ONLY_HEADER] == "default-src 'self'" + + response = view_without_decorator(REQUEST) + mw.process_response(REQUEST, response) + assert response[REPORT_ONLY_HEADER] == "default-src 'self'" + assert HEADER not in response + + +def test_csp_append_with_csp(): + @csp_append(extra={'font-src': ['bar.com']}) + @csp(default={'img-src': ['foo.com'], 'report_only': True}) + def view_with_decorator(request): + return HttpResponse() + response = view_with_decorator(REQUEST) + assert response._csp_config == { + 'default': { + 'img-src': ['foo.com'], + 'report_only': True, + }, + 'extra': { + 'font-src': ['bar.com'], + } + } + assert not hasattr(response, "_csp_select") + mw.process_response(REQUEST, response) + assert response[HEADER] == "font-src bar.com" + assert response[REPORT_ONLY_HEADER] == "img-src foo.com" + + +@override_settings(CSP_POLICIES=("report", "default")) +def test_csp_append_with_csp_select(): + @csp_append(extra={'font-src': ['bar.com']}) + @csp_select("report") + def view_with_decorator(request): + return HttpResponse() + response = view_with_decorator(REQUEST) + assert response._csp_config == { + 'extra': { + 'font-src': ['bar.com'], + } + } + assert response._csp_select == ("report", "extra") + mw.process_response(REQUEST, response) + assert response[HEADER] == "font-src bar.com" + assert response[REPORT_ONLY_HEADER] == "default-src 'self'" + + +@override_settings(CSP_POLICIES=("report", "default")) +def test_csp_append_with_csp_and_csp_select(): + @csp_append(extra={'font-src': ['bar.com']}) + @csp(default={'img-src': ['foo.com'], 'report_only': True}) + @csp_select("report") + def view_with_decorator(request): + return HttpResponse() + response = view_with_decorator(REQUEST) + assert response._csp_config == { + 'default': { + 'img-src': ['foo.com'], + 'report_only': True, + }, + 'extra': { + 'font-src': ['bar.com'], + } + } + assert response._csp_select == ("report", "extra") + mw.process_response(REQUEST, response) + assert response[HEADER] == "font-src bar.com" + assert response[REPORT_ONLY_HEADER] == "default-src 'self'" diff --git a/csp/tests/test_middleware.py b/csp/tests/test_middleware.py index ce06b24..f65209d 100644 --- a/csp/tests/test_middleware.py +++ b/csp/tests/test_middleware.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.http import ( HttpResponse, HttpResponseServerError, @@ -6,15 +7,23 @@ from django.test import RequestFactory from django.test.utils import override_settings -from csp.middleware import CSPMiddleware -from csp.tests.utils import response +import pytest +from csp.middleware import CSPMiddleware +from csp.tests.utils import override_legacy_settings, response +from csp.utils import HTTP_HEADERS -HEADER = 'Content-Security-Policy' +HEADER_SET = set(HTTP_HEADERS) +HEADER, REPORT_ONLY_HEADER = HTTP_HEADERS mw = CSPMiddleware(response()) rf = RequestFactory() +def get_headers(response): + # TODO: use response.headers for Django 3.2+ + return set(header for header, _ in response.items()) + + def test_add_header(): request = rf.get('/') response = HttpResponse() @@ -27,24 +36,31 @@ def test_exempt(): response = HttpResponse() response._csp_exempt = True mw.process_response(request, response) - assert HEADER not in response + assert not HEADER_SET.intersection(get_headers(response)) -@override_settings(CSP_EXCLUDE_URL_PREFIXES=('/inlines-r-us')) -def text_exclude(): +@override_settings( + CSP_POLICIES=('default', 'report'), +) +def test_exclude(): + settings.CSP_POLICY_DEFINITIONS['default']['exclude_url_prefixes'] = ( + '/inlines-r-us', + ) request = rf.get('/inlines-r-us/foo') response = HttpResponse() mw.process_response(request, response) assert HEADER not in response + assert response[REPORT_ONLY_HEADER] == "default-src 'self'" + settings.CSP_POLICY_DEFINITIONS['default']['exclude_url_prefixes'] = () -@override_settings(CSP_REPORT_ONLY=True) +@override_legacy_settings(CSP_REPORT_ONLY=True) def test_report_only(): request = rf.get('/') response = HttpResponse() mw.process_response(request, response) assert HEADER not in response - assert HEADER + '-Report-Only' in response + assert REPORT_ONLY_HEADER in response def test_dont_replace(): @@ -53,25 +69,98 @@ def test_dont_replace(): response[HEADER] = 'default-src example.com' mw.process_response(request, response) assert response[HEADER] == 'default-src example.com' + response = HttpResponse() + response[REPORT_ONLY_HEADER] = 'default-src example.com' + mw.process_response(request, response) + assert response[REPORT_ONLY_HEADER] == 'default-src example.com' + + +def test_dont_replace_all(): + request = rf.get('/') + response = HttpResponse() + response[HEADER] = 'default-src example.com' + response[REPORT_ONLY_HEADER] = 'default-src example.com' + mw.process_response(request, response) + assert response[REPORT_ONLY_HEADER] == 'default-src example.com' def test_use_config(): request = rf.get('/') response = HttpResponse() - response._csp_config = {'default-src': ['example.com']} + response._csp_config = {'default': { + 'default-src': ['example.com'], + }} mw.process_response(request, response) assert response[HEADER] == 'default-src example.com' +def test_use_complex_config(): + request = rf.get('/') + response = HttpResponse() + response._csp_config = { + 'default': { + 'default-src': ['example.com'], + }, + 'report': { + 'img-src': ['test.example.com'], + 'report_only': True, + }, + } + mw.process_response(request, response) + assert response[HEADER] == 'default-src example.com' + assert response[REPORT_ONLY_HEADER] == 'img-src test.example.com' + + +def test_use_select(): + request = rf.get('/') + response = HttpResponse() + response._csp_config = { + 'alt': { + 'default-src': ['example.com'], + }, + 'child': { + 'child-src': ['child.example.com'], + }, + 'report_test': { + 'img-src': ['test.example.com'], + 'report_only': True, + }, + } + response._csp_select = ('child', 'default', 'report_test') + mw.process_response(request, response) + policies = sorted(response[HEADER].split(', ')) + assert policies == ["child-src child.example.com", "default-src 'self'"] + assert response[REPORT_ONLY_HEADER] == 'img-src test.example.com' + + +def test_use_select_dne(): + request = rf.get('/') + response = HttpResponse() + response._csp_select = ('does_not_exist',) + with pytest.raises(KeyError): + mw.process_response(request, response) + + def test_use_update(): request = rf.get('/') response = HttpResponse() - response._csp_update = {'default-src': ['example.com']} + response._csp_update = { + 'default': { + 'default-src': ['example.com'], + # FIXME: This won't work. Should it? + 'report_only': True, + }, + 'does_not_exist': { + 'default-src': ['dne.example.com'], + 'report_only': True, + }, + } mw.process_response(request, response) assert response[HEADER] == "default-src 'self' example.com" + assert REPORT_ONLY_HEADER not in response -@override_settings(CSP_IMG_SRC=['foo.com']) +@override_legacy_settings(CSP_IMG_SRC=['foo.com']) def test_use_replace(): request = rf.get('/') response = HttpResponse() @@ -81,39 +170,80 @@ def test_use_replace(): assert policy_list == ["default-src 'self'", "img-src bar.com"] -@override_settings(DEBUG=True) +@override_legacy_settings(CSP_IMG_SRC=['foo.com']) +def test_use_complex_replace(): + request = rf.get('/') + response = HttpResponse() + response._csp_replace = { + 'does_not_exist': { + 'img-src': ['bar.com'], + }, + 'default': { + 'child-src': ['child.example.com'], + 'default-src': ['example.com'], + 'report_only': True, + }, + } + mw.process_response(request, response) + policy_list = sorted(response[REPORT_ONLY_HEADER].split('; ')) + assert policy_list == [ + "child-src child.example.com", + "default-src example.com", + "img-src foo.com", + ] + assert HEADER not in response + + +@override_settings( + DEBUG=True, + CSP_POLICIES=("default", "report"), +) def test_debug_errors_exempt(): request = rf.get('/') response = HttpResponseServerError() mw.process_response(request, response) - assert HEADER not in response + assert not HEADER_SET.intersection(get_headers(response)) -@override_settings(DEBUG=True) +@override_settings( + DEBUG=True, + CSP_POLICIES=("default", "report"), +) def test_debug_notfound_exempt(): request = rf.get('/') response = HttpResponseNotFound() mw.process_response(request, response) - assert HEADER not in response + assert not HEADER_SET.intersection(get_headers(response)) +@override_settings( + CSP_POLICIES=("default", "report"), +) def test_nonce_created_when_accessed(): request = rf.get('/') mw.process_request(request) nonce = str(request.csp_nonce) response = HttpResponse() mw.process_response(request, response) - assert nonce in response[HEADER] + for header in HEADER_SET: + assert nonce in response[header] +@override_settings( + CSP_POLICIES=("default", "report"), +) def test_no_nonce_when_not_accessed(): request = rf.get('/') mw.process_request(request) response = HttpResponse() mw.process_response(request, response) - assert 'nonce-' not in response[HEADER] + for header in HEADER_SET: + assert 'nonce-' not in response[header] +@override_settings( + CSP_POLICIES=("default", "report"), +) def test_nonce_regenerated_on_new_request(): request1 = rf.get('/') request2 = rf.get('/') @@ -127,11 +257,14 @@ def test_nonce_regenerated_on_new_request(): response2 = HttpResponse() mw.process_response(request1, response1) mw.process_response(request2, response2) - assert nonce1 not in response2[HEADER] - assert nonce2 not in response1[HEADER] + for header in HEADER_SET: + assert nonce1 not in response2[header] + assert nonce2 not in response1[header] -@override_settings(CSP_INCLUDE_NONCE_IN=[]) +@override_legacy_settings( + CSP_INCLUDE_NONCE_IN=[], +) def test_no_nonce_when_disabled_by_settings(): request = rf.get('/') mw.process_request(request) diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py index 5a4afd8..afdb868 100644 --- a/csp/tests/test_utils.py +++ b/csp/tests/test_utils.py @@ -1,147 +1,165 @@ from __future__ import absolute_import -import six from django.conf import settings -from django.test.utils import override_settings +from django.test import override_settings from django.utils.functional import lazy -from csp.utils import build_policy +import pytest +from csp.tests.utils import override_legacy_settings +from csp.utils import ( + build_policy, + _policies_from_names_and_kwargs, +) -def policy_eq(a, b, msg='%r != %r'): - parts_a = sorted(a.split('; ')) - parts_b = sorted(b.split('; ')) - assert parts_a == parts_b, msg % (a, b) + +def policy_eq( + a, b, msg='%r != %r', report_only=False, exclude_url_prefixes=(), +): + if not isinstance(a, list): + a = [(a, report_only, exclude_url_prefixes)] + if not isinstance(b, list): + b = [(b, report_only, exclude_url_prefixes)] + + for ( + (csp_a, report_only_a, exclude_prefixes_a), + (csp_b, report_only_b, exclude_prefixes_b), + ) in zip(a, b): + assert report_only_a == report_only_b + assert sorted(exclude_prefixes_a) == sorted(exclude_prefixes_b) + parts_a = sorted(csp_a.split('; ')) + parts_b = sorted(csp_b.split('; ')) + assert parts_a == parts_b, msg % (a, b) def test_empty_policy(): policy = build_policy() - assert "default-src 'self'" == policy + policy_eq("default-src 'self'", policy) def literal(s): return s -lazy_literal = lazy(literal, six.text_type) +lazy_literal = lazy(literal, str) -@override_settings(CSP_DEFAULT_SRC=['example.com', 'example2.com']) +@override_legacy_settings(CSP_DEFAULT_SRC=['example.com', 'example2.com']) def test_default_src(): policy = build_policy() - assert 'default-src example.com example2.com' == policy + policy_eq('default-src example.com example2.com', policy) -@override_settings(CSP_SCRIPT_SRC=['example.com']) +@override_legacy_settings(CSP_SCRIPT_SRC=['example.com']) def test_script_src(): policy = build_policy() policy_eq("default-src 'self'; script-src example.com", policy) -@override_settings(CSP_SCRIPT_SRC_ATTR=['example.com']) +@override_legacy_settings(CSP_SCRIPT_SRC_ATTR=['example.com']) def test_script_src_attr(): policy = build_policy() policy_eq("default-src 'self'; script-src-attr example.com", policy) -@override_settings(CSP_SCRIPT_SRC_ELEM=['example.com']) +@override_legacy_settings(CSP_SCRIPT_SRC_ELEM=['example.com']) def test_script_src_elem(): policy = build_policy() policy_eq("default-src 'self'; script-src-elem example.com", policy) -@override_settings(CSP_OBJECT_SRC=['example.com']) +@override_legacy_settings(CSP_OBJECT_SRC=['example.com']) def test_object_src(): policy = build_policy() policy_eq("default-src 'self'; object-src example.com", policy) -@override_settings(CSP_PREFETCH_SRC=['example.com']) +@override_legacy_settings(CSP_PREFETCH_SRC=['example.com']) def test_prefetch_src(): policy = build_policy() policy_eq("default-src 'self'; prefetch-src example.com", policy) -@override_settings(CSP_STYLE_SRC=['example.com']) +@override_legacy_settings(CSP_STYLE_SRC=['example.com']) def test_style_src(): policy = build_policy() policy_eq("default-src 'self'; style-src example.com", policy) -@override_settings(CSP_STYLE_SRC_ATTR=['example.com']) +@override_legacy_settings(CSP_STYLE_SRC_ATTR=['example.com']) def test_style_src_attr(): policy = build_policy() policy_eq("default-src 'self'; style-src-attr example.com", policy) -@override_settings(CSP_STYLE_SRC_ELEM=['example.com']) +@override_legacy_settings(CSP_STYLE_SRC_ELEM=['example.com']) def test_style_src_elem(): policy = build_policy() policy_eq("default-src 'self'; style-src-elem example.com", policy) -@override_settings(CSP_IMG_SRC=['example.com']) +@override_legacy_settings(CSP_IMG_SRC=['example.com']) def test_img_src(): policy = build_policy() policy_eq("default-src 'self'; img-src example.com", policy) -@override_settings(CSP_MEDIA_SRC=['example.com']) +@override_legacy_settings(CSP_MEDIA_SRC=['example.com']) def test_media_src(): policy = build_policy() policy_eq("default-src 'self'; media-src example.com", policy) -@override_settings(CSP_FRAME_SRC=['example.com']) +@override_legacy_settings(CSP_FRAME_SRC=['example.com']) def test_frame_src(): policy = build_policy() policy_eq("default-src 'self'; frame-src example.com", policy) -@override_settings(CSP_FONT_SRC=['example.com']) +@override_legacy_settings(CSP_FONT_SRC=['example.com']) def test_font_src(): policy = build_policy() policy_eq("default-src 'self'; font-src example.com", policy) -@override_settings(CSP_CONNECT_SRC=['example.com']) +@override_legacy_settings(CSP_CONNECT_SRC=['example.com']) def test_connect_src(): policy = build_policy() policy_eq("default-src 'self'; connect-src example.com", policy) -@override_settings(CSP_SANDBOX=['allow-scripts']) +@override_legacy_settings(CSP_SANDBOX=['allow-scripts']) def test_sandbox(): policy = build_policy() policy_eq("default-src 'self'; sandbox allow-scripts", policy) -@override_settings(CSP_SANDBOX=[]) +@override_legacy_settings(CSP_SANDBOX=[]) def test_sandbox_empty(): policy = build_policy() policy_eq("default-src 'self'; sandbox", policy) -@override_settings(CSP_REPORT_URI='/foo') +@override_legacy_settings(CSP_REPORT_URI='/foo') def test_report_uri(): policy = build_policy() policy_eq("default-src 'self'; report-uri /foo", policy) -@override_settings(CSP_REPORT_URI=lazy_literal('/foo')) +@override_legacy_settings(CSP_REPORT_URI=lazy_literal('/foo')) def test_report_uri_lazy(): policy = build_policy() policy_eq("default-src 'self'; report-uri /foo", policy) -@override_settings(CSP_REPORT_TO='some_endpoint') +@override_legacy_settings(CSP_REPORT_TO='some_endpoint') def test_report_to(): policy = build_policy() policy_eq("default-src 'self'; report-to some_endpoint", policy) -@override_settings(CSP_IMG_SRC=['example.com']) +@override_legacy_settings(CSP_IMG_SRC=['example.com']) def test_update_img(): policy = build_policy(update={'img-src': 'example2.com'}) policy_eq("default-src 'self'; img-src example.com example2.com", @@ -154,7 +172,7 @@ def test_update_missing_setting(): policy_eq("default-src 'self'; img-src example.com", policy) -@override_settings(CSP_IMG_SRC=['example.com']) +@override_legacy_settings(CSP_IMG_SRC=['example.com']) def test_replace_img(): policy = build_policy(replace={'img-src': 'example2.com'}) policy_eq("default-src 'self'; img-src example2.com", policy) @@ -172,7 +190,7 @@ def test_config(): policy_eq("default-src 'none'; img-src 'self'", policy) -@override_settings(CSP_IMG_SRC=('example.com',)) +@override_legacy_settings(CSP_IMG_SRC=('example.com',)) def test_update_string(): """ GitHub issue #40 - given project settings as a tuple, and @@ -183,7 +201,7 @@ def test_update_string(): policy) -@override_settings(CSP_IMG_SRC=('example.com',)) +@override_legacy_settings(CSP_IMG_SRC=('example.com',)) def test_replace_string(): """ Demonstrate that GitHub issue #40 doesn't affect replacements @@ -193,83 +211,87 @@ def test_replace_string(): policy) -@override_settings(CSP_FORM_ACTION=['example.com']) +@override_legacy_settings(CSP_FORM_ACTION=['example.com']) def test_form_action(): policy = build_policy() policy_eq("default-src 'self'; form-action example.com", policy) -@override_settings(CSP_BASE_URI=['example.com']) +@override_legacy_settings(CSP_BASE_URI=['example.com']) def test_base_uri(): policy = build_policy() policy_eq("default-src 'self'; base-uri example.com", policy) -@override_settings(CSP_CHILD_SRC=['example.com']) +@override_legacy_settings(CSP_CHILD_SRC=['example.com']) def test_child_src(): policy = build_policy() policy_eq("default-src 'self'; child-src example.com", policy) -@override_settings(CSP_FRAME_ANCESTORS=['example.com']) +@override_legacy_settings(CSP_FRAME_ANCESTORS=['example.com']) def test_frame_ancestors(): policy = build_policy() policy_eq("default-src 'self'; frame-ancestors example.com", policy) -@override_settings(CSP_NAVIGATE_TO=['example.com']) +@override_legacy_settings(CSP_NAVIGATE_TO=['example.com']) def test_navigate_to(): policy = build_policy() policy_eq("default-src 'self'; navigate-to example.com", policy) -@override_settings(CSP_MANIFEST_SRC=['example.com']) +@override_legacy_settings(CSP_MANIFEST_SRC=['example.com']) def test_manifest_src(): policy = build_policy() policy_eq("default-src 'self'; manifest-src example.com", policy) -@override_settings(CSP_WORKER_SRC=['example.com']) +@override_legacy_settings(CSP_WORKER_SRC=['example.com']) def test_worker_src(): policy = build_policy() policy_eq("default-src 'self'; worker-src example.com", policy) -@override_settings(CSP_PLUGIN_TYPES=['application/pdf']) +@override_legacy_settings(CSP_PLUGIN_TYPES=['application/pdf']) def test_plugin_types(): policy = build_policy() policy_eq("default-src 'self'; plugin-types application/pdf", policy) -@override_settings(CSP_REQUIRE_SRI_FOR=['script']) +@override_legacy_settings(CSP_REQUIRE_SRI_FOR=['script']) def test_require_sri_for(): policy = build_policy() policy_eq("default-src 'self'; require-sri-for script", policy) -@override_settings(CSP_REQUIRE_TRUSTED_TYPES_FOR=["'script'"]) +@override_legacy_settings(CSP_REQUIRE_TRUSTED_TYPES_FOR=["'script'"]) def test_require_trusted_types_for(): policy = build_policy() policy_eq("default-src 'self'; require-trusted-types-for 'script'", policy) -@override_settings(CSP_TRUSTED_TYPES=["strictPolicy", "laxPolicy", - "default", "'allow-duplicates'"]) +@override_legacy_settings( + CSP_TRUSTED_TYPES=[ + "strictPolicy", "laxPolicy", "default", "'allow-duplicates'", + ], +) def test_trusted_types(): policy = build_policy() policy_eq("default-src 'self'; trusted-types strictPolicy laxPolicy " + "default 'allow-duplicates'", policy) -@override_settings(CSP_UPGRADE_INSECURE_REQUESTS=True) +@override_legacy_settings(CSP_UPGRADE_INSECURE_REQUESTS=True) def test_upgrade_insecure_requests(): policy = build_policy() policy_eq("default-src 'self'; upgrade-insecure-requests", policy) -@override_settings(CSP_BLOCK_ALL_MIXED_CONTENT=True) +@override_legacy_settings(CSP_BLOCK_ALL_MIXED_CONTENT=True) def test_block_all_mixed_content(): - policy = build_policy() + with pytest.warns(DeprecationWarning): + policy = build_policy() policy_eq("default-src 'self'; block-all-mixed-content", policy) @@ -278,7 +300,7 @@ def test_nonce(): policy_eq("default-src 'self' 'nonce-abc123'", policy) -@override_settings(CSP_INCLUDE_NONCE_IN=['script-src', 'style-src']) +@override_legacy_settings(CSP_INCLUDE_NONCE_IN=['script-src', 'style-src']) def test_nonce_include_in(): policy = build_policy(nonce='abc123') policy_eq(("default-src 'self'; " @@ -286,8 +308,13 @@ def test_nonce_include_in(): "style-src 'nonce-abc123'"), policy) -@override_settings() +@override_settings(CSP_POLICIES=('report',)) def test_nonce_include_in_absent(): - del settings.CSP_INCLUDE_NONCE_IN + assert 'include_nonce_in' not in settings.CSP_POLICY_DEFINITIONS['report'] policy = build_policy(nonce='abc123') - policy_eq("default-src 'self' 'nonce-abc123'", policy) + policy_eq("default-src 'self' 'nonce-abc123'", policy, report_only=True) + + +def test_policies_from_names_and_kwargs(): + with pytest.raises(TypeError): + _policies_from_names_and_kwargs(None, {}) diff --git a/csp/tests/utils.py b/csp/tests/utils.py index e51ae10..64c4bf6 100644 --- a/csp/tests/utils.py +++ b/csp/tests/utils.py @@ -1,10 +1,22 @@ +from contextlib import contextmanager + +from django.conf import settings from django.http import HttpResponse from django.template import engines, Template, Context -from django.test import RequestFactory +from django.test import override_settings, RequestFactory from csp.middleware import CSPMiddleware +@contextmanager +def override_legacy_settings(**overrides): + with override_settings(): + del settings.CSP_POLICY_DEFINITIONS + for key, value in overrides.items(): + setattr(settings, key, value) + yield + + def response(*args, headers=None, **kwargs): def get_response(req): response = HttpResponse(*args, **kwargs) diff --git a/csp/utils.py b/csp/utils.py index 35a73be..bfb54f9 100644 --- a/csp/utils.py +++ b/csp/utils.py @@ -1,90 +1,140 @@ import copy import re +import warnings from collections import OrderedDict from itertools import chain -from django.conf import settings +from django.utils.crypto import get_random_string from django.utils.encoding import force_str +import http.client as http_client -def from_settings(): - return { - # Fetch Directives - 'child-src': getattr(settings, 'CSP_CHILD_SRC', None), - 'connect-src': getattr(settings, 'CSP_CONNECT_SRC', None), - 'default-src': getattr(settings, 'CSP_DEFAULT_SRC', ["'self'"]), - 'script-src': getattr(settings, 'CSP_SCRIPT_SRC', None), - 'script-src-attr': getattr(settings, 'CSP_SCRIPT_SRC_ATTR', None), - 'script-src-elem': getattr(settings, 'CSP_SCRIPT_SRC_ELEM', None), - 'object-src': getattr(settings, 'CSP_OBJECT_SRC', None), - 'style-src': getattr(settings, 'CSP_STYLE_SRC', None), - 'style-src-attr': getattr(settings, 'CSP_STYLE_SRC_ATTR', None), - 'style-src-elem': getattr(settings, 'CSP_STYLE_SRC_ELEM', None), - 'font-src': getattr(settings, 'CSP_FONT_SRC', None), - 'frame-src': getattr(settings, 'CSP_FRAME_SRC', None), - 'img-src': getattr(settings, 'CSP_IMG_SRC', None), - 'manifest-src': getattr(settings, 'CSP_MANIFEST_SRC', None), - 'media-src': getattr(settings, 'CSP_MEDIA_SRC', None), - 'prefetch-src': getattr(settings, 'CSP_PREFETCH_SRC', None), - 'worker-src': getattr(settings, 'CSP_WORKER_SRC', None), - # Document Directives - 'base-uri': getattr(settings, 'CSP_BASE_URI', None), - 'plugin-types': getattr(settings, 'CSP_PLUGIN_TYPES', None), - 'sandbox': getattr(settings, 'CSP_SANDBOX', None), - # Navigation Directives - 'form-action': getattr(settings, 'CSP_FORM_ACTION', None), - 'frame-ancestors': getattr(settings, 'CSP_FRAME_ANCESTORS', None), - 'navigate-to': getattr(settings, 'CSP_NAVIGATE_TO', None), - # Reporting Directives - 'report-uri': getattr(settings, 'CSP_REPORT_URI', None), - 'report-to': getattr(settings, 'CSP_REPORT_TO', None), - 'require-sri-for': getattr(settings, 'CSP_REQUIRE_SRI_FOR', None), - #trusted Types Directives - 'require-trusted-types-for': getattr( - settings, - 'CSP_REQUIRE_TRUSTED_TYPES_FOR', None), - 'trusted-types': getattr(settings, 'CSP_TRUSTED_TYPES', None), - # Other Directives - 'upgrade-insecure-requests': getattr( - settings, 'CSP_UPGRADE_INSECURE_REQUESTS', False), - 'block-all-mixed-content': getattr( - settings, 'CSP_BLOCK_ALL_MIXED_CONTENT', False), - } - - -def build_policy(config=None, update=None, replace=None, nonce=None): - """Builds the policy as a string from the settings.""" +from .conf import ( + defaults, + deprecation, + directive_to_setting, + get_declared_policies, + get_declared_policy_definitions, + setting_to_directive, + DIRECTIVES, +) - if config is None: - config = from_settings() - # Be careful, don't mutate config as it could be from settings - update = update if update is not None else {} - replace = replace if replace is not None else {} - csp = {} +HTTP_HEADERS = ( + 'Content-Security-Policy', + 'Content-Security-Policy-Report-Only', +) - for k in set(chain(config, replace)): - if k in replace: - v = replace[k] - else: - v = config[k] - if v is not None: - v = copy.copy(v) - if not isinstance(v, (list, tuple)): - v = (v,) - csp[k] = v +EXEMPTED_DEBUG_CODES = { + http_client.INTERNAL_SERVER_ERROR, + http_client.NOT_FOUND, +} + +SINGLE_POLICY_KWARGS = {directive_to_setting(d, prefix='') for d in DIRECTIVES} + + +def _normalize_config(config, key='default'): + """ + Permits the config to be a single policy, which will be returned under the + 'default' key by default. + """ + if config is None: + return {} + if not config: + return config + + if not isinstance(next(iter(config.values())), dict): + return {'default': config} + return config + + +def build_policy( + config=None, + update=None, + replace=None, + nonce=None, + select=None, +): + """ + Builds the policy from the settings as a list of tuples: + (policy_string, report_only) + """ + + base_config = get_declared_policy_definitions() + if config: + config = _normalize_config(config) + base_config.update(config) + if not select: + select = config.keys() + replace = _normalize_config(replace) or {} + update = _normalize_config(update) or {} + config = {} + for name, policy in base_config.items(): + policy = _replace_policy(policy, replace.get(name, {})) + if update: + update_policy = update.get(name) + if update_policy is not None: + _update_policy(policy, update_policy) + config[name] = policy + + if not select: # empty select not permitted: use csp_exempt instead + select = get_declared_policies() + policies = (config[name] for name in select) + + return [_compile_policy(csp, nonce=nonce) for csp in policies] + + +def _update_policy(csp, update): for k, v in update.items(): if v is not None: if not isinstance(v, (list, tuple)): v = (v,) + if csp.get(k) is None: csp[k] = v else: csp[k] += tuple(v) - report_uri = csp.pop('report-uri', None) + +def _replace_policy(csp, replace): + new_policy = {} + for k in set(chain(csp, replace)): + if k in replace: + v = replace[k] + else: + v = csp[k] + if v is not None: + v = copy.copy(v) + if not isinstance(v, (list, tuple)): + v = (v,) + new_policy[k] = v + return new_policy + + +def _compile_policy(csp, nonce=None): + """ + Compile a content security policy, returning a 3-tuple: + header_value, report_only, exclude_url_prefixes + """ + report_uri = csp.pop( + 'report-uri', + defaults.POLICY['report-uri'], + ) + report_only = csp.pop( + 'report_only', + # every directive is normalized to a tuple/list at this point + (defaults.POLICY['report_only'],), + )[0] + include_nonce_in = csp.pop( + 'include_nonce_in', + defaults.POLICY['include_nonce_in'] + ) + exclude_url_prefixes = csp.pop( + 'exclude_url_prefixes', + defaults.POLICY['exclude_url_prefixes'], + ) policy_parts = {} for key, value in csp.items(): @@ -96,20 +146,102 @@ def build_policy(config=None, update=None, replace=None, nonce=None): else: # directives with many values like src lists policy_parts[key] = ' '.join(value) + if key == 'block-all-mixed-content': + warnings.warn( + deprecation.BLOCK_ALL_MIXED_CONTENT_DEPRECATION_WARNING, + DeprecationWarning, + ) + if report_uri: report_uri = map(force_str, report_uri) policy_parts['report-uri'] = ' '.join(report_uri) if nonce: - include_nonce_in = getattr(settings, 'CSP_INCLUDE_NONCE_IN', - ['default-src']) for section in include_nonce_in: policy = policy_parts.get(section, '') policy_parts[section] = ("%s %s" % (policy, "'nonce-%s'" % nonce)).strip() - return '; '.join(['{} {}'.format(k, val).strip() - for k, val in policy_parts.items()]) + policy_string = '; '.join( + '{} {}'.format(k, val).strip() for k, val in policy_parts.items() + ) + + return policy_string, report_only, exclude_url_prefixes + + +def kwarg_to_directive(kwarg, value=None): + return setting_to_directive(kwarg, prefix='', value=value) + + +class PolicyNames: + length = 20 + last_policy_name = None + + def __next__(self): + self.last_policy_name = get_random_string(self.length) + return self.last_policy_name + + def __iter__(self): + return self + + +policy_names = PolicyNames() + + +def _clean_input_policy(policy): + return dict( + kwarg_to_directive(in_directive, value=value) + for in_directive, value in policy.items() + ) + + +def _kwargs_to_directives(kwargs, name_generator): + keys = {key.upper() for key in kwargs} + if keys.intersection(SINGLE_POLICY_KWARGS): + # Single-policy kwargs is the legacy behaviour (deprecate?) + invalid_keys = keys.difference(SINGLE_POLICY_KWARGS) + if invalid_keys: + raise TypeError( # Python uses a type error in this case + "got unexpected keyword arguments %s." + % ", ".join(invalid_keys) + + " If legacy settings are passed to a csp decorator, all " + + "kwargs must be legacy settings.", + ) + return { + name: _clean_input_policy(kwargs) + for name in name_generator + } + # else: a dictionary of named policies + return kwargs + + +def _policies_from_names_and_kwargs(csp_names, kwargs): + """ + Helper used in csp_update and csp_replace to process args + """ + if not kwargs: + raise TypeError("missing required keyword arguments.") + + return _kwargs_to_directives(kwargs, csp_names) + + +def _policies_from_args_and_kwargs(args, kwargs): + all_definitions = {} + if args: # A list of policy dictionaries + all_definitions.update({ + next(policy_names): policy + for policy in args + }) + + if kwargs: + all_definitions.update( + _kwargs_to_directives( + kwargs, + (next(policy_names) for _ in range(1)) + ), + ) + + return all_definitions def _default_attr_mapper(attr_name, val): diff --git a/docs/conf.py b/docs/conf.py index b9969f7..a63e102 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -121,7 +121,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. diff --git a/docs/configuration.rst b/docs/configuration.rst index c65ee10..03c038d 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -10,8 +10,18 @@ you may need to tweak here. It's worth reading the latest CSP spec_ and making sure you understand it before configuring django-csp. +Multiple policies can be configured using the below settings. There are two +reasons to do this: + +1. To configure one policy as report-only and another to be enforced. + +2. To have multiple policies enforced simultaneously for a directive, e.g +a ``{'include_nonce_in': ['default-src']}`` and ``{'default-src': ['self']}``. + +Multiple policies for the same header will be separated by a ``,`` in the header. + .. note:: - Many settings require a ``tuple`` or ``list``. You may get very strange + Many directives require a ``tuple`` or ``list``. You may get very strange policies and even errors when mistakenly configuring them as a ``string``. @@ -26,6 +36,37 @@ These settings affect the policy in the header. The defaults are in *italics*. quoted! e.g.: ``CSP_DEFAULT_SRC = ("'self'",)``. Without quotes they will not work as intended. +``CSP_POLICY_DEFINITIONS`` + A dictionary of dictionaries of directives or pseudo-directives. *{'default': default_policy}* + + `default_policy` uses the defaults for each directive as shown in :ref:`deprecated-policy-settings` + and :ref:`deprecated-pseudo-directives` below. + + The policy directives are lower-case and use dashes (``-``) rather than (``_``) used by the + :ref:`old settings`, with the exception of the + :ref:`deprecated-pseudo-directives` (``report_only``, ``exclude_url_prefixes``, and + ``include_nonce_in``) which are specified with underscores rather than dashes to distinguish + them visually from the csp directives and for forwards compatibility. + +``CSP_POLICIES`` + A list or tuple specifying which definitions will be applied by default and + defining an order on those policies. *['default']* + + Note that not all policies defined in ``CSP_POLICY_DEFINITIONS`` need to be used here. Those that + aren't can be selected for a particular view using the ``@csp_select`` + :ref:`decorator `. + + +.. _deprecated-policy-settings: + +Deprecated Policy Settings +-------------------------- + +With the introduction of multi-policy support, the following settings are deprecated. +If ``CSP_POLICY_DEFINITIONS`` is not defined, they will be used to populate it until +the deprecation period is over. It will be populated with a single policy under the +``default`` key. + ``CSP_DEFAULT_SRC`` Set the ``default-src`` directive. A ``tuple`` or ``list`` of values, e.g.: ``("'self'", 'cdn.example.net')``. *["'self'"]* @@ -147,9 +188,18 @@ These settings affect the policy in the header. The defaults are in *italics*. ``CSP_BLOCK_ALL_MIXED_CONTENT`` Include ``block-all-mixed-content`` directive. A ``boolean``. *False* - + Note: Obsolete. All mixed content is now blocked if it can't be autoupgraded. See: block-all-mixed-content_ + +.. _deprecated-pseudo-directives: + +Pseudo-Directives +^^^^^^^^^^^^^^^^^ + +These settings affect how the policy is applied, but do not correspond with a single +csp directive. + ``CSP_INCLUDE_NONCE_IN`` Include dynamically generated nonce in all listed directives. A ``tuple`` or ``list``, e.g.: ``CSP_INCLUDE_NONCE_IN = ['script-src']`` @@ -159,20 +209,6 @@ These settings affect the policy in the header. The defaults are in *italics*. Note: The nonce value will only be generated if ``request.csp_nonce`` is accessed during the request/response cycle. - -Changing the Policy -------------------- - -The policy can be changed on a per-view (or even per-request) basis. See -the :ref:`decorator documentation ` for more details. - - -Other Settings -============== - -These settings control the behavior of django-csp. Defaults are in -*italics*. - ``CSP_REPORT_ONLY`` Send "report-only" headers instead of real headers. A ``boolean``. *False* @@ -192,9 +228,16 @@ These settings control the behavior of django-csp. Defaults are in on, e.g., ``excluded-page/`` can therefore be leveraged to access everything on the same origin. +Changing the Policy +------------------- + +The policy can be changed on a per-view (or even per-request) basis. See +the :ref:`decorator documentation ` for more details. + + .. _Content-Security-Policy: https://www.w3.org/TR/CSP/ .. _Content-Security-Policy-L3: https://w3c.github.io/webappsec-csp/ .. _spec: Content-Security-Policy_ .. _require-sri-for-known-tokens: https://w3c.github.io/webappsec-subresource-integrity/#opt-in-require-sri-for .. _upgrade-insecure-requests: https://w3c.github.io/webappsec-upgrade-insecure-requests/#delivery -.. _block-all-mixed-content: https://w3c.github.io/webappsec-mixed-content/ +.. _block-all-mixed-content: https://w3c.github.io/webappsec-mixed-content/#strict-checking diff --git a/docs/decorators.rst b/docs/decorators.rst index c5eb3f3..0e3dff1 100644 --- a/docs/decorators.rst +++ b/docs/decorators.rst @@ -9,17 +9,19 @@ You may, on some views, need to expand or change the policy. django-csp includes four decorators to help. +.. _csp-exempt-decorator: + ``@csp_exempt`` =============== -Using the ``@csp_exempt`` decorator disables the CSP header on a given +Using the ``@csp_exempt`` decorator disables the CSP headers on a given view. :: from csp.decorators import csp_exempt - # Will not have a CSP header. + # Will not have CSP headers. @csp_exempt def myview(request): return render(...) @@ -27,70 +29,186 @@ view. You can manually set this on a per-response basis by setting the ``_csp_exempt`` attribute on the response to ``True``:: - # Also will not have a CSP header. + # Also will not have CSP headers. def myview(request): response = render(...) response._csp_exempt = True return response -``@csp_update`` +.. _csp-select-decorator: + +``@csp_select`` =============== -The ``@csp_update`` header allows you to **append** values to the source -lists specified in the settings. If there is no setting, the value -passed to the decorator will be used verbatim. +The ``@csp_select`` decorator allows you to select policies to include +from the current policy definitions, including those added +through the ``@csp`` :ref:`decorator ` or the +``@csp_append`` :ref:`decorator ` and those +defined in ``CSP_POLICY_DEFINITIONS``. -.. note:: - To quote the CSP spec: "There's no inheritance; ... the default list - is not used for that resource type" if it is set. E.g., the following - will not allow images from 'self':: +The arguments are positional-only names of policies. - default-src 'self'; img-src imgsrv.com +It effectively overrides the ``CSP_POLICIES`` setting for a single view. +:: + + from csp.decorators import csp_select + + # Will first apply the default policy and the alt second policy. + @csp_select('default', 'first') + @csp({ + 'alt': { + 'default-src': ["'self'"], + 'img-src': ['imgsrv.com'], + 'report-only': True, + }, + }) + def myview(request): + return render(...) -The arguments to the decorator the same as the :ref:`settings -` without the ``CSP_`` prefix, e.g. ``IMG_SRC``. -(They are also case-insensitive.) The values are either strings, lists -or tuples. +.. _csp-update-decorator: + +``@csp_update`` +=============== + +The ``@csp_update`` decorator allows you to **append** values to the source +lists specified in a policy. If there is no setting, the value +passed to the decorator will be used verbatim. There are two different +parameter formats: + +1. Keyword arguments that are the uppercased CSP directives, with dashes +replaced by underscores (the same as the :ref:`deprecated-policy-settings` +without the ``CSP_`` prefix, but case-insensitive). The values are either +strings, lists or tuples. In this mode of calling there is an optional +positional argument specifying which named policies to which to apply the +directives (default: ``('default',)``). :: from csp.decorators import csp_update - # Will allow images from imgsrv.com. + # Will allow images from imgsrv.com in the default policy. @csp_update(IMG_SRC='imgsrv.com') def myview(request): return render(...) +2. Keyword arguments that are named policies equivalent to the format of +``CSP_POLICY_DEFINITIONS``. +:: + + from csp.decorators import csp_update + + # Will allow images from imgsrv.com in the default policy. + @csp_update(default={'img-src': 'imgsrv.com'}) + def myview(request): + return render(...) + +.. note:: + To quote the CSP spec: "There's no inheritance; ... the default list + is not used for that resource type" if it is set. E.g., the following + will not allow images from 'self':: + + default-src 'self'; img-src imgsrv.com + + +.. _csp-replace-decorator: + ``@csp_replace`` ================ The ``@csp_replace`` decorator allows you to **replace** a source list -specified in settings. If there is no setting, the value passed to the -decorator will be used verbatim. (See the note under ``@csp_update``.) +specified in a policy. Setting a directive to ``None`` will delete that +directive from the policy. If there is no setting, the value passed to the +decorator will be used verbatim. See the note under +``@csp_update`` :ref:`decorator `. -The arguments and values are the same as ``@csp_update``:: +The arguments and values are the same as ``@csp_update`` +:: from csp.decorators import csp_replace - # settings.CSP_IMG_SRC = ['imgsrv.com'] + # settings.CSP_POLICY_DEFINITIONS = {'default': {'img-src': 'imgsrv.com'}} # Will allow images from imgsrv2.com, but not imgsrv.com. @csp_replace(IMG_SRC='imgsrv2.com') def myview(request): return render(...) + # OR + + @csp_replace(default={'img-src': 'imgsrv2.com'}) + def myview(request): + return render(...) + + +.. _csp-decorator: ``@csp`` ======== -If you need to set the entire policy on a view, ignoring all the -settings, you can use the ``@csp`` decorator. The arguments and values -are as above:: +If you need to replace the entire policy list on a view, ignoring all the +settings, you can use the ``@csp`` decorator. + +The ``@csp_select`` :ref:`decorator ` can be used to +combine these with the policies configured in ``CSP_POLICY_DEFINITIONS`` +(but see also the ``@csp_append`` :ref:`decorator ` below). + +The arguments and values are the same as the ``@csp_update`` +:ref:`decorator ` except that it accepts optional position +arguments that are unnamed policies. +:: from csp.decorators import csp - @csp(DEFAULT_SRC=["'self'"], IMG_SRC=['imgsrv.com'], - SCRIPT_SRC=['scriptsrv.com', 'googleanalytics.com']) + @csp( + DEFAULT_SRC=["'self'"], + IMG_SRC=['imgsrv.com'], + SCRIPT_SRC=['scriptsrv.com', 'googleanalytics.com'], + ) + def myview(request): + return render(...) + + # OR + + @csp(new={ + default-src=["'self'"], + img-src=['imgsrv.com'], + script-src=['scriptsrv.com', 'googleanalytics.com'], + }) + def myview(request): + return render(...) + + # OR + + @csp({ + default-src=["'self'"], + img-src=['imgsrv.com'], + script-src=['scriptsrv.com', 'googleanalytics.com'], + }) + def myview(request): + return render(...) + + +.. _csp-append-decorator: + +``@csp_append`` +=============== + +The ``@csp_append`` decorator allows you to add a new policy to +the policies configured in settings to a view. + +The arguments and values are the same as the ``@csp`` +:ref:`decorator `. +:: + + from csp.decorators import csp_append + + # Add this stricter policy as report_only for myview. + @csp_append({ + default-src=["'self'"], + img-src=['imgsrv.com'], + script-src=['scriptsrv.com', 'googleanalytics.com'], + report_only=True, + }) def myview(request): return render(...) diff --git a/docs/installation.rst b/docs/installation.rst index 7745419..821b1c0 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -24,6 +24,7 @@ to ``MIDDLEWARE``, like so:: 'csp.middleware.CSPMiddleware', # ... ) + Note: Middleware order does not matter unless you have other middleware modifying the CSP header. That should do it! Go on to :ref:`configuring CSP `. diff --git a/setup.cfg b/setup.cfg index b92ce01..1c620a1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,4 +1,4 @@ [tool:pytest] -addopts = -vs --tb=short --pep8 --flakes +addopts = -vs --tb=short --pycodestyle --flakes --ignore docs/conf.py DJANGO_SETTINGS_MODULE = csp.tests.settings diff --git a/setup.py b/setup.py index 95cae78..858eb89 100644 --- a/setup.py +++ b/setup.py @@ -31,14 +31,13 @@ def read(*parts): ] test_requires = [ - 'pytest<4.0', + 'pytest<8.0', 'pytest-cov', 'pytest-django', - 'pytest-flakes==1.0.1', - 'pytest-pep8==1.0.6', - 'pep8==1.4.6', + 'pytest-flakes==4.0.5', + 'pytest-pycodestyle==2.2.1', + 'pycodestyle==2.8.0', 'mock==1.0.1', - 'six==1.12.0', ] test_requires += jinja2_requires diff --git a/tox.ini b/tox.ini index 65c4dbd..5bbd99d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,10 @@ [tox] envlist = - {3.6,3.7,3.8,3.9,pypy3}-main - {3.6,3.7,3.8,3.9,pypy3}-3.0.x - {3.6,3,7,3.8,3.9,pypy3}-2.2.x + {3.8,3.9,pypy3.9}-main + {3.8,3.9,pypy3.9}-4.0.x + {3.6,3.7,3.8,3.9,pypy3}-3.2.x + {3.6,3.7,3.8,3.9,pypy3}-2.2.x + docs [testenv] setenv = @@ -18,8 +20,18 @@ basepython = 3.8: python3.8 3.9: python3.9 pypy3: pypy3 + pypy3.9: pypy3.9 deps= pytest 2.2.x: Django>=2.2,<2.3 3.2.x: Django>=3.2,<3.3 + 4.0.x: Django>=4.0,<4.1 main: https://github.com/django/django/archive/main.tar.gz + +[testenv:docs] +allowlist_externals = make +basepython = python3 +skip_install = true +commands = make -C docs html +deps= + sphinx