From 3227426e79c14d4057b91f5c52db74a4c39e39a4 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Fri, 6 Mar 2020 01:01:20 -0400 Subject: [PATCH 01/35] GH-36 Update settings for multi-policy support - Refactor settings --- csp/conf/__init__.py | 26 +++++++++++ csp/conf/defaults.py | 44 +++++++++++++++++++ csp/conf/deprecation.py | 51 ++++++++++++++++++++++ csp/decorators.py | 1 - csp/middleware.py | 25 +++++------ csp/utils.py | 95 +++++++++++++++++++++++------------------ 6 files changed, 185 insertions(+), 57 deletions(-) create mode 100644 csp/conf/__init__.py create mode 100644 csp/conf/defaults.py create mode 100644 csp/conf/deprecation.py diff --git a/csp/conf/__init__.py b/csp/conf/__init__.py new file mode 100644 index 0000000..aaf64c8 --- /dev/null +++ b/csp/conf/__init__.py @@ -0,0 +1,26 @@ +from . import defaults + + +DIRECTIVES = set(defaults.POLICY) +PSEUDO_DIRECTIVES = {d for d in DIRECTIVES if '_' in d} + + +def setting_to_directive(setting, value, prefix='CSP_'): + setting = setting[len(prefix):].lower() + if setting not in PSEUDO_DIRECTIVES: + setting = setting.replace('_', '-') + assert setting in 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_KWARGS = {directive_to_setting(d, prefix='') for d in DIRECTIVES} diff --git a/csp/conf/defaults.py b/csp/conf/defaults.py new file mode 100644 index 0000000..402b543 --- /dev/null +++ b/csp/conf/defaults.py @@ -0,0 +1,44 @@ +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, + # Pseudo Directives + 'report_only': False, + 'include_nonce_in': ('default-src',), + 'exclude_url_prefixes': (), +} diff --git a/csp/conf/deprecation.py b/csp/conf/deprecation.py new file mode 100644 index 0000000..fa80f02 --- /dev/null +++ b/csp/conf/deprecation.py @@ -0,0 +1,51 @@ +import warnings + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +from . import ( + setting_to_directive, + directive_to_setting, + DIRECTIVES, +) + + +LEGACY_SETTINGS_NAMES_DEPRECATION_WARNING = ( + 'The following settings are deprecated: %s. ' + 'Use CSP_POLICY_DEFINITIONS and CSP_POLICIES instead.' +) + + +_LEGACY_SETTINGS = { + directive_to_setting(directive) for directive in 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( + "Settings CSP_POLICY_DEFINITIONS is not allowed with 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/decorators.py b/csp/decorators.py index bce3352..257e70c 100644 --- a/csp/decorators.py +++ b/csp/decorators.py @@ -1,6 +1,5 @@ from functools import wraps - def csp_exempt(f): @wraps(f) def _wrapped(*a, **kw): diff --git a/csp/middleware.py b/csp/middleware.py index 73397e1..3649699 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -7,12 +7,6 @@ from django.conf import settings 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: @@ -23,7 +17,10 @@ class MiddlewareMixin(object): """ pass -from csp.utils import build_policy +from .conf import defaults +from .utils import ( + build_policy, EXEMPTED_DEBUG_CODES, +) class CSPMiddleware(MiddlewareMixin): @@ -55,17 +52,17 @@ def process_response(self, request, response): return response # Check for ignored path prefix. - prefixes = getattr(settings, 'CSP_EXCLUDE_URL_PREFIXES', ()) + # TODO: Legacy setting + prefixes = getattr( + settings, + 'CSP_EXCLUDE_URL_PREFIXES', + defaults.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' diff --git a/csp/utils.py b/csp/utils.py index 35a73be..0e54cdc 100644 --- a/csp/utils.py +++ b/csp/utils.py @@ -7,50 +7,48 @@ from django.conf import settings from django.utils.encoding import force_str +try: + from django.utils.six.moves import http_client +except ImportError: + # django 3.x removed six + import http.client as http_client + +from .conf import ( + defaults, deprecation, + setting_to_directive, PSEUDO_DIRECTIVES, +) + + +HTTP_HEADERS = ( + 'Content-Security-Policy', + 'Content-Security-Policy-Report-Only', +) + + +EXEMPTED_DEBUG_CODES = { + http_client.INTERNAL_SERVER_ERROR, + http_client.NOT_FOUND, +} + 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), - } + policies = getattr(settings, 'CSP_POLICIES', defaults.POLICIES) + definitions = csp_definitions_update({}, defaults.POLICY_DEFINITIONS) + custom_definitions = getattr( + settings, + 'CSP_POLICY_DEFINITIONS', + {'default': {}}, + ) + # Technically we're modifying Django settings here, + # but it shouldn't matter since the end result of either will be the same + deprecation._handle_legacy_settings(custom_definitions) + for name, csp in custom_definitions.items(): + definitions[name].update(csp) + # TODO: Error handling + # TODO: Remove in October 2020 when ordered dicts are the default + return OrderedDict( + (name, definitions[name]) for name in policies + ) def build_policy(config=None, update=None, replace=None, nonce=None): @@ -185,3 +183,16 @@ def build_script_tag(content=None, **kwargs): c = _unwrap_script(content) if content and not kwargs.get('src') else '' attrs = ATTR_FORMAT_STR.format(**data).rstrip() return ('{}'.format(attrs, c).strip()) + + +def kwarg_to_directive(kwarg, value=None): + return setting_to_directive(kwarg, prefix='', value=value) + + +def csp_definitions_update(csp_definitions, other): + """ Update one csp defnitions dictionary with another """ + if isinstance(other, dict): + other = other.items() + for name, csp in other: + csp_definitions.setdefault(name, {}).update(csp) + return csp_definitions From 2d7aa80cf27633aac62b0679ee7d2d71c994f220 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Fri, 6 Mar 2020 01:04:59 -0400 Subject: [PATCH 02/35] GH-36 Update utils to handle multiple policies --- csp/utils.py | 278 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 222 insertions(+), 56 deletions(-) diff --git a/csp/utils.py b/csp/utils.py index 0e54cdc..964aa65 100644 --- a/csp/utils.py +++ b/csp/utils.py @@ -14,8 +14,10 @@ import http.client as http_client from .conf import ( - defaults, deprecation, - setting_to_directive, PSEUDO_DIRECTIVES, + defaults, + deprecation, + setting_to_directive, + LEGACY_KWARGS, ) @@ -31,58 +33,132 @@ } -def from_settings(): - policies = getattr(settings, 'CSP_POLICIES', defaults.POLICIES) - definitions = csp_definitions_update({}, defaults.POLICY_DEFINITIONS) - custom_definitions = getattr( - settings, - 'CSP_POLICY_DEFINITIONS', - {'default': {}}, +def get_declared_policy_definitions(): + custom_definitions = csp_definitions_update( + {}, + getattr( + settings, + 'CSP_POLICY_DEFINITIONS', + {'default': {}}, + ), ) - # Technically we're modifying Django settings here, - # but it shouldn't matter since the end result of either will be the same - deprecation._handle_legacy_settings(custom_definitions) - for name, csp in custom_definitions.items(): - definitions[name].update(csp) - # TODO: Error handling - # TODO: Remove in October 2020 when ordered dicts are the default - return OrderedDict( - (name, definitions[name]) for name in policies + deprecation._handle_legacy_settings( + custom_definitions['default'], + allow_legacy=True # 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 build_policy(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 +def get_declared_policies(): + return getattr(settings, 'CSP_POLICIES', defaults.POLICIES) - update = update if update is not None else {} - replace = replace if replace is not None else {} - csp = {} - - 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 +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(): @@ -99,15 +175,118 @@ def build_policy(config=None, update=None, replace=None, nonce=None): 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) + + +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 + + +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) + if in_directive.isupper() else (in_directive, value) + for in_directive, value in policy.items() + ) + + +def iter_policies(policies, name_generator=policy_names): + """ + Accepts the following formats: + - a policy dictionary (formatted like in settings.CSP_POLICY_DEFINITIONS) + - an iterable of policies: (item, item, item,...) + + item can be any of the following: + - subscriptable two-tuple: (name, csp) + - csp dictionary which will be assigned a random name + + Yields a tuple: (name, csp) + """ + if isinstance(policies, dict): + yield from ( + (name, _clean_input_policy(policy)) + for name, policy in policies.items() + ) + return + + for policy in policies: + if isinstance(policy, (list, tuple)): + yield (policy[0], _clean_input_policy(policy[1])) + else: # dictionary containing a single csp + yield (next(name_generator), _clean_input_policy(policy)) + + +def _kwargs_are_directives(kwargs): + keys = set(kwargs) + if keys.intersection(LEGACY_KWARGS): # Legacy settings + # Single-policy kwargs is the legacy behaviour (deprecate?) + if keys.difference(LEGACY_KWARGS): + raise ValueError( + "If legacy settings are passed to the csp decorator, all " + "kwargs must be legacy settings." + ) + return False + # else: a dictionary of named policies + return True + + +def _policies_from_names_and_kwargs(csp_names, kwargs): + """ + Helper used in csp_update and csp_replace to process args + """ + if kwargs: + if not _kwargs_are_directives(kwargs): + policy = _clean_input_policy(kwargs) + return {name: policy for name in csp_names} + return dict(iter_policies(kwargs)) + else: + raise ValueError("kwargs must not be empty.") + + +def _policies_from_args_and_kwargs(args, kwargs): + all_definitions = [] + if args: # A list of policy dictionaries + all_definitions.append(iter_policies(args)) + + if kwargs: + if not _kwargs_are_directives(kwargs): + kwargs = [kwargs] + all_definitions.append(iter_policies(kwargs)) + + return dict(chain(*all_definitions)) def _default_attr_mapper(attr_name, val): @@ -183,16 +362,3 @@ def build_script_tag(content=None, **kwargs): c = _unwrap_script(content) if content and not kwargs.get('src') else '' attrs = ATTR_FORMAT_STR.format(**data).rstrip() return ('{}'.format(attrs, c).strip()) - - -def kwarg_to_directive(kwarg, value=None): - return setting_to_directive(kwarg, prefix='', value=value) - - -def csp_definitions_update(csp_definitions, other): - """ Update one csp defnitions dictionary with another """ - if isinstance(other, dict): - other = other.items() - for name, csp in other: - csp_definitions.setdefault(name, {}).update(csp) - return csp_definitions From 290a57d3b0b1c499865e3901ba958bb62af0cc64 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Fri, 6 Mar 2020 01:51:50 -0400 Subject: [PATCH 03/35] GH-36 Update csp decorators for multi-policy support --- csp/decorators.py | 29 +++++++++++++++++++---------- csp/utils.py | 1 + 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/csp/decorators.py b/csp/decorators.py index 257e70c..847cb91 100644 --- a/csp/decorators.py +++ b/csp/decorators.py @@ -1,4 +1,11 @@ from functools import wraps +from itertools import chain + +from .utils import ( + _policies_from_args_and_kwargs, + _policies_from_names_and_kwargs, +) + def csp_exempt(f): @wraps(f) @@ -9,8 +16,11 @@ def _wrapped(*a, **kw): return _wrapped -def csp_update(**kwargs): - update = dict((k.lower().replace('_', '-'), v) for k, v in kwargs.items()) +def csp_update(csp_names=('default',), **kwargs): + update = _policies_from_names_and_kwargs( + csp_names, + kwargs, + ) def decorator(f): @wraps(f) @@ -22,8 +32,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) @@ -35,12 +48,8 @@ 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(*args, **kwargs): + config = _policies_from_args_and_kwargs(args, kwargs) def decorator(f): @wraps(f) diff --git a/csp/utils.py b/csp/utils.py index 964aa65..04be945 100644 --- a/csp/utils.py +++ b/csp/utils.py @@ -5,6 +5,7 @@ from itertools import chain from django.conf import settings +from django.utils.crypto import get_random_string from django.utils.encoding import force_str try: From 91402dabe34fe7b4b7bc9b85293d484447dc3b41 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Fri, 6 Mar 2020 21:28:44 -0400 Subject: [PATCH 04/35] GH-36 Update middleware for multi-policy support --- csp/middleware.py | 57 ++++++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/csp/middleware.py b/csp/middleware.py index 3649699..344f5f4 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -2,6 +2,7 @@ import os import base64 +from collections import defaultdict from functools import partial from django.conf import settings @@ -17,9 +18,8 @@ class MiddlewareMixin(object): """ pass -from .conf import defaults from .utils import ( - build_policy, EXEMPTED_DEBUG_CODES, + build_policy, EXEMPTED_DEBUG_CODES, HTTP_HEADERS, ) @@ -51,36 +51,43 @@ def process_response(self, request, response): if getattr(response, '_csp_exempt', False): return response - # Check for ignored path prefix. - # TODO: Legacy setting - prefixes = getattr( - settings, - 'CSP_EXCLUDE_URL_PREFIXES', - defaults.EXCLUDE_URL_PREFIXES, - ) - if request.path_info.startswith(prefixes): - return response - # Check for debug view 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(): + response[header] = '; '.join(policies) return response 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) + build_kwargs = { + key: getattr(response, '_csp_%s' % key, None) + for key in ('config', 'update', 'replace', 'select') + } + return build_policy( + nonce=getattr(request, '_csp_nonce', None), + **build_kwargs, + ) From 83e2f61dd528ecc053170335815693cf94854b68 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Tue, 10 Mar 2020 00:13:41 -0300 Subject: [PATCH 05/35] GH-36 Update tests to use new multi-policy format --- csp/tests/settings.py | 11 ++++++--- csp/tests/test_decorators.py | 44 ++++++++++++++++++++++-------------- csp/tests/test_middleware.py | 8 +++++-- csp/tests/test_utils.py | 19 +++++++++++----- 4 files changed, 54 insertions(+), 28 deletions(-) diff --git a/csp/tests/settings.py b/csp/tests/settings.py index dc74ff1..f909e10 100644 --- a/csp/tests/settings.py +++ b/csp/tests/settings.py @@ -1,9 +1,14 @@ import django -CSP_REPORT_ONLY = False - -CSP_INCLUDE_NONCE_IN = ['default-src'] +CSP_POLICY_DEFINITIONS = { + 'default': { + 'report_only': False, + }, + 'report': { + 'report_only': True, + }, +} DATABASES = { 'default': { diff --git a/csp/tests/test_decorators.py b/csp/tests/test_decorators.py index a4bd733..d370d82 100644 --- a/csp/tests/test_decorators.py +++ b/csp/tests/test_decorators.py @@ -5,8 +5,10 @@ from csp.decorators import csp, csp_replace, csp_update, csp_exempt from csp.middleware import CSPMiddleware from csp.tests.utils import response +from csp.utils import policy_names, HTTP_HEADERS +HEADER, REPORT_ONLY_HEADER = HTTP_HEADERS REQUEST = RequestFactory().get('/') mw = CSPMiddleware(response()) @@ -25,21 +27,21 @@ 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 dict(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"] @@ -49,21 +51,21 @@ 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 dict(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,7 +73,7 @@ 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'"] @@ -80,22 +82,26 @@ 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'"] @@ -105,8 +111,12 @@ def test_csp_string_values(): 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 dict(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"] diff --git a/csp/tests/test_middleware.py b/csp/tests/test_middleware.py index ce06b24..e8fc858 100644 --- a/csp/tests/test_middleware.py +++ b/csp/tests/test_middleware.py @@ -58,7 +58,9 @@ def test_dont_replace(): 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' @@ -66,7 +68,9 @@ def test_use_config(): def test_use_update(): request = rf.get('/') response = HttpResponse() - response._csp_update = {'default-src': ['example.com']} + response._csp_update = {'default': { + 'default-src': ['example.com'] + }} mw.process_response(request, response) assert response[HEADER] == "default-src 'self' example.com" diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py index 5a4afd8..a023ffc 100644 --- a/csp/tests/test_utils.py +++ b/csp/tests/test_utils.py @@ -8,15 +8,22 @@ from csp.utils import build_policy -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): + if not isinstance(a, list): + a = [(a, report_only)] + if not isinstance(a, list): + b = [(b, report_only)] + + for csp_a, csp_b in zip(a, b): + parts_a = sorted(csp_a[0].split('; ')) + parts_b = sorted(csp_b[0].split('; ')) + assert csp_a[1] == csp_b[1] + assert parts_a == parts_b, msg % (a, b) def test_empty_policy(): policy = build_policy() - assert "default-src 'self'" == policy + assert [("default-src 'self'", False)] == policy def literal(s): @@ -29,7 +36,7 @@ def literal(s): @override_settings(CSP_DEFAULT_SRC=['example.com', 'example2.com']) def test_default_src(): policy = build_policy() - assert 'default-src example.com example2.com' == policy + assert [('default-src example.com example2.com', False)] == policy @override_settings(CSP_SCRIPT_SRC=['example.com']) From ad9cbae9d96b774d0d6f4158545750c8f6cb40fa Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Mon, 23 May 2022 23:48:49 -0300 Subject: [PATCH 06/35] GH-36 Add csp_select and csp_append decorators --- csp/decorators.py | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/csp/decorators.py b/csp/decorators.py index 847cb91..3835f48 100644 --- a/csp/decorators.py +++ b/csp/decorators.py @@ -2,6 +2,7 @@ from itertools import chain from .utils import ( + get_declared_policies, _policies_from_args_and_kwargs, _policies_from_names_and_kwargs, ) @@ -16,6 +17,20 @@ def _wrapped(*a, **kw): return _wrapped +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, @@ -48,6 +63,41 @@ def _wrapped(*a, **kw): return decorator +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) From c6cfa87dd73a9f8e6bd59de5a14a4138e66e6c5d Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Mon, 23 May 2022 23:56:01 -0300 Subject: [PATCH 07/35] GH-36 Add tests for multi-policy support and csp_append/csp_select decorators --- csp/tests/test_decorators.py | 216 ++++++++++++++++++++++++++++++++++- csp/tests/test_middleware.py | 162 +++++++++++++++++++++++--- csp/tests/test_utils.py | 15 ++- 3 files changed, 369 insertions(+), 24 deletions(-) diff --git a/csp/tests/test_decorators.py b/csp/tests/test_decorators.py index d370d82..d962d9f 100644 --- a/csp/tests/test_decorators.py +++ b/csp/tests/test_decorators.py @@ -2,7 +2,9 @@ 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.utils import policy_names, HTTP_HEADERS @@ -21,6 +23,30 @@ def view(request): assert response._csp_exempt +@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_settings(CSP_IMG_SRC=['foo.com']) def test_csp_update(): def view_without_decorator(request): @@ -45,6 +71,26 @@ def view_with_decorator(request): assert policy_list == ["default-src 'self'", "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_settings(CSP_IMG_SRC=['foo.com']) def test_csp_replace(): def view_without_decorator(request): @@ -77,6 +123,25 @@ def view_removing_directive(request): 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() @@ -105,6 +170,64 @@ def view_with_decorator(request): assert policy_list == ["default-src 'self'"] +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') @@ -120,3 +243,94 @@ def view_with_decorator(request): mw.process_response(REQUEST, response) 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 e8fc858..0fa34ea 100644 --- a/csp/tests/test_middleware.py +++ b/csp/tests/test_middleware.py @@ -6,15 +6,23 @@ from django.test import RequestFactory from django.test.utils import override_settings +import pytest + from csp.middleware import CSPMiddleware from csp.tests.utils import 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,15 +35,19 @@ 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'), + CSP_EXCLUDE_URL_PREFIXES=('/inlines-r-us',), +) +def test_exclude(): 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'" @override_settings(CSP_REPORT_ONLY=True) @@ -44,7 +56,7 @@ def test_report_only(): 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,6 +65,19 @@ 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(): @@ -65,14 +90,70 @@ def test_use_config(): 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_order(): + 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) + policy_list = sorted(response[HEADER].split('; ')) + assert policy_list == ["child-src child.example.com", "default-src 'self'"] + assert response[REPORT_ONLY_HEADER] == 'img-src test.example.com' + + +def test_use_order_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': { - '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']) @@ -85,39 +166,80 @@ def test_use_replace(): assert policy_list == ["default-src 'self'", "img-src bar.com"] -@override_settings(DEBUG=True) +@override_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('/') @@ -131,10 +253,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_POLICIES=("default", "report"), +) @override_settings(CSP_INCLUDE_NONCE_IN=[]) def test_no_nonce_when_disabled_by_settings(): request = rf.get('/') @@ -143,3 +269,5 @@ def test_no_nonce_when_disabled_by_settings(): response = HttpResponse() mw.process_response(request, response) assert nonce not in response[HEADER] + # Legacy settings only apply to default + assert nonce in response[REPORT_ONLY_HEADER] diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py index a023ffc..fdb6199 100644 --- a/csp/tests/test_utils.py +++ b/csp/tests/test_utils.py @@ -8,22 +8,25 @@ from csp.utils import build_policy -def policy_eq(a, b, msg='%r != %r', report_only=False): +def policy_eq( + a, b, msg='%r != %r', report_only=False, exclude_url_prefixes=(), +): if not isinstance(a, list): - a = [(a, report_only)] + a = [(a, report_only, exclude_url_prefixes)] if not isinstance(a, list): - b = [(b, report_only)] + b = [(b, report_only, exclude_url_prefixes)] for csp_a, csp_b in zip(a, b): + assert csp_a[1] == csp_b[1] + assert sorted(csp_a[2]) == sorted(csp_b[2]) parts_a = sorted(csp_a[0].split('; ')) parts_b = sorted(csp_b[0].split('; ')) - assert csp_a[1] == csp_b[1] assert parts_a == parts_b, msg % (a, b) def test_empty_policy(): policy = build_policy() - assert [("default-src 'self'", False)] == policy + policy_eq("default-src 'self'", policy) def literal(s): @@ -36,7 +39,7 @@ def literal(s): @override_settings(CSP_DEFAULT_SRC=['example.com', 'example2.com']) def test_default_src(): policy = build_policy() - assert [('default-src example.com example2.com', False)] == policy + policy_eq('default-src example.com example2.com', policy) @override_settings(CSP_SCRIPT_SRC=['example.com']) From 6c25fc93031404596d7c34a3c1daf52ee008f261 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Tue, 10 Mar 2020 14:40:06 -0300 Subject: [PATCH 08/35] GH-36 Update docs for multi-policy support --- docs/configuration.rst | 56 +++++++++++++++++++++++++++++++----------- docs/decorators.rst | 41 +++++++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 19 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index c65ee10..875e064 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -26,6 +26,32 @@ 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 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. + +``CSP_POLICIES`` + A list or tuple specifying which definitions will be applied by default and + defining an order on those policies. *['default']* + + +.. 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'"]* @@ -150,6 +176,15 @@ These settings affect the policy in the header. The defaults are in *italics*. 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 +194,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,6 +213,13 @@ 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_ diff --git a/docs/decorators.rst b/docs/decorators.rst index c5eb3f3..bbb4f53 100644 --- a/docs/decorators.rst +++ b/docs/decorators.rst @@ -54,7 +54,6 @@ The arguments to the decorator the same as the :ref:`settings or tuples. :: - from csp.decorators import csp_update # Will allow images from imgsrv.com. @@ -70,8 +69,8 @@ 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``.) -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'] @@ -81,13 +80,45 @@ The arguments and values are the same as ``@csp_update``:: return render(...) +``@csp_select`` +=============== + +The ``@csp_select`` decorator allows you to select policies to include +from the current policy definitions being applied. + +It accepts a mixed iterable of names or indices into the compiled definitions. + +It acts very much like the ``CSP_POLICIES`` setting except that it can use +indices, which don't work for ``CSP_POLICIES`` because it's used to define +the order on the compiled policy in the first place). + +NOTE: in the case of passing a ``dict`` to one of the other decorators, +the order will not be well-defined before Python 3.6. +Avoid using indices in this cases. + +For named policies it will fallback to ``CSP_POLICY_DEFINITIONS`` even if they +don't appear in the current policy, so use with care +:: + from csp.decorators import csp_select + + # Using default settings + # Will first apply the default policy, then the second policy, then the first policy + @csp_select(['default', 1, 0]) # or @csp_select(['default', 'second', 'first]) + @csp(csp_definitions=( + ('first', {'default-src': ["'self'"], 'img-src': ['imgsrv.com']}), + ('second', {'script-src': ['scriptsrv.com', 'googleanalytics.com']}, + )) + def myview(request): + return render(...) + + ``@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:: - +are as above +:: from csp.decorators import csp @csp(DEFAULT_SRC=["'self'"], IMG_SRC=['imgsrv.com'], From 09a0ce66e5d0e359e42eb220d9cf5f4df3908240 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Mon, 23 May 2022 22:58:25 -0300 Subject: [PATCH 09/35] GH-36 Update CHANGES for multi-policy support --- CHANGES | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES b/CHANGES index 90b5406..280eb8a 100644 --- a/CHANGES +++ b/CHANGES @@ -10,6 +10,9 @@ 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. 3.7 === From e2e66a9acf485952784454e1db1fb9b9e966f886 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Mon, 23 May 2022 23:57:04 -0300 Subject: [PATCH 10/35] TEMP: Add utils tests --- csp/tests/test_utils.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py index fdb6199..95119f4 100644 --- a/csp/tests/test_utils.py +++ b/csp/tests/test_utils.py @@ -5,7 +5,12 @@ from django.test.utils import override_settings from django.utils.functional import lazy -from csp.utils import build_policy +import pytest + +from csp.utils import ( + build_policy, + _policies_from_names_and_kwargs, +) def policy_eq( @@ -301,3 +306,8 @@ def test_nonce_include_in_absent(): del settings.CSP_INCLUDE_NONCE_IN policy = build_policy(nonce='abc123') policy_eq("default-src 'self' 'nonce-abc123'", policy) + + +def test_policies_from_names_and_kwargs(): + with pytest.raises(ValueError): + _policies_from_names_and_kwargs(None, {}) From 90b72febcadb00cd89bebf0499f42258e8dff9b2 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Mon, 23 May 2022 23:48:17 -0300 Subject: [PATCH 11/35] Remove legacy django and python support (GH-36) --- csp/middleware.py | 11 +---------- csp/tests/test_utils.py | 3 +-- csp/utils.py | 6 +----- setup.py | 1 - 4 files changed, 3 insertions(+), 18 deletions(-) diff --git a/csp/middleware.py b/csp/middleware.py index 344f5f4..cb13333 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -6,18 +6,9 @@ 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.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 .utils import ( build_policy, EXEMPTED_DEBUG_CODES, HTTP_HEADERS, ) diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py index 95119f4..5f796b1 100644 --- a/csp/tests/test_utils.py +++ b/csp/tests/test_utils.py @@ -1,6 +1,5 @@ from __future__ import absolute_import -import six from django.conf import settings from django.test.utils import override_settings from django.utils.functional import lazy @@ -38,7 +37,7 @@ 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']) diff --git a/csp/utils.py b/csp/utils.py index 04be945..84a9254 100644 --- a/csp/utils.py +++ b/csp/utils.py @@ -8,11 +8,7 @@ from django.utils.crypto import get_random_string from django.utils.encoding import force_str -try: - from django.utils.six.moves import http_client -except ImportError: - # django 3.x removed six - import http.client as http_client +import http.client as http_client from .conf import ( defaults, diff --git a/setup.py b/setup.py index 95cae78..26d0c30 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,6 @@ def read(*parts): 'pytest-pep8==1.0.6', 'pep8==1.4.6', 'mock==1.0.1', - 'six==1.12.0', ] test_requires += jinja2_requires From 1a7f979840e7a1c117f6cc5f78b1d1432a2e6d22 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Sun, 8 Mar 2020 22:52:09 -0300 Subject: [PATCH 12/35] Exclude docs/conf.py from tests (GH-36) --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index b92ce01..bb9a8f1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,4 +1,4 @@ [tool:pytest] -addopts = -vs --tb=short --pep8 --flakes +addopts = -vs --tb=short --pep8 --flakes --ignore docs/conf.py DJANGO_SETTINGS_MODULE = csp.tests.settings From 29efdf74aa2e6a5bfb506e46d5dcefa0e6939473 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Mon, 23 May 2022 20:08:51 -0300 Subject: [PATCH 13/35] TODO: upgrade test deps --- setup.cfg | 2 +- setup.py | 8 ++++---- tox.ini | 11 ++++++++--- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/setup.cfg b/setup.cfg index bb9a8f1..1c620a1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,4 +1,4 @@ [tool:pytest] -addopts = -vs --tb=short --pep8 --flakes --ignore docs/conf.py +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 26d0c30..858eb89 100644 --- a/setup.py +++ b/setup.py @@ -31,12 +31,12 @@ 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', ] diff --git a/tox.ini b/tox.ini index 65c4dbd..b9894aa 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 + # {pypy3.9}-4.1.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 [testenv] setenv = @@ -18,8 +20,11 @@ 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 + 4.1.x: https://github.com/django/django/archive/1d071ec1aa8fa414bb96b41f7be8a1bd01079815.tar.gz From 858ec860de799a3f731cf546e33387a3d7b5b8d3 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Tue, 24 May 2022 00:54:12 -0300 Subject: [PATCH 14/35] Fix docs build warnings (GH-36) --- docs/conf.py | 2 +- docs/decorators.rst | 3 +++ docs/installation.rst | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) 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/decorators.rst b/docs/decorators.rst index bbb4f53..7f1ce83 100644 --- a/docs/decorators.rst +++ b/docs/decorators.rst @@ -54,6 +54,7 @@ The arguments to the decorator the same as the :ref:`settings or tuples. :: + from csp.decorators import csp_update # Will allow images from imgsrv.com. @@ -71,6 +72,7 @@ decorator will be used verbatim. (See the note under ``@csp_update``.) The arguments and values are the same as ``@csp_update`` :: + from csp.decorators import csp_replace # settings.CSP_IMG_SRC = ['imgsrv.com'] @@ -119,6 +121,7 @@ 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 :: + from csp.decorators import csp @csp(DEFAULT_SRC=["'self'"], IMG_SRC=['imgsrv.com'], 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 `. From 6c0de34e9c3995ff49d77649374be8644bae07a7 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Tue, 24 May 2022 00:54:32 -0300 Subject: [PATCH 15/35] TEMP Update docs --- docs/configuration.rst | 4 ++-- docs/decorators.rst | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 875e064..dcf5ee6 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -42,7 +42,7 @@ These settings affect the policy in the header. The defaults are in *italics*. defining an order on those policies. *['default']* -.. deprecated-policy-settings: +.. _deprecated-policy-settings: Deprecated Policy Settings -------------------------- @@ -177,7 +177,7 @@ the deprecation period is over. It will be populated with a single policy under See: block-all-mixed-content_ -.. deprecated-pseudo-directives: +.. _deprecated-pseudo-directives: Pseudo-Directives ^^^^^^^^^^^^^^^^^ diff --git a/docs/decorators.rst b/docs/decorators.rst index 7f1ce83..75809c5 100644 --- a/docs/decorators.rst +++ b/docs/decorators.rst @@ -101,6 +101,7 @@ Avoid using indices in this cases. For named policies it will fallback to ``CSP_POLICY_DEFINITIONS`` even if they don't appear in the current policy, so use with care :: + from csp.decorators import csp_select # Using default settings From 02d06bcc821fd5d894aee4fbb4d6d680fa12504a Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Tue, 24 May 2022 01:33:22 -0300 Subject: [PATCH 16/35] Add docs to tox.ini (GH-36) --- tox.ini | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index b9894aa..f9c8f98 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,7 @@ envlist = {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 = @@ -27,4 +28,12 @@ deps= 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 - 4.1.x: https://github.com/django/django/archive/1d071ec1aa8fa414bb96b41f7be8a1bd01079815.tar.gz + 4.1.x: https://github.com/django/django/archive/1d071ec1aa8fa414bb96b41f7be8a1bd01079815.tar.gz + +[testenv:docs] +allowlist_externals = make +basepython = python3 +skip_install = true +commands = make -C docs html +deps= + sphinx From c7bde5c3196e393fbbcd92fc3a7e9fd7280f401f Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Tue, 24 May 2022 02:03:02 -0300 Subject: [PATCH 17/35] GH-36 Update RateLimitedCSPMiddleware to support csp_select decorator --- csp/contrib/rate_limiting.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/csp/contrib/rate_limiting.py b/csp/contrib/rate_limiting.py index 8a4d087..724ae54 100644 --- a/csp/contrib/rate_limiting.py +++ b/csp/contrib/rate_limiting.py @@ -11,8 +11,10 @@ class RateLimitedCSPMiddleware(CSPMiddleware): 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) + build_kwargs = { + key: getattr(response, '_csp_%s' % key, None) + for key in ('config', 'update', 'select') + } replace = getattr(response, '_csp_replace', {}) nonce = getattr(request, '_csp_nonce', None) @@ -21,5 +23,8 @@ 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( + replace=replace, + nonce=nonce, + **build_kwargs, + ) From 50de343cdc12a1ab40d04d084c89cceeab17dff4 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Tue, 24 May 2022 22:31:27 -0300 Subject: [PATCH 18/35] fixup! GH-36 Update CHANGES for multi-policy support --- CHANGES | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES b/CHANGES index 280eb8a..9cbb117 100644 --- a/CHANGES +++ b/CHANGES @@ -13,6 +13,10 @@ Next - 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. 3.7 === From 9fff323f7a6c40459d767fc2e19cbdffa000ca6c Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Tue, 24 May 2022 23:37:29 -0300 Subject: [PATCH 19/35] Deprecate block-all-mixed-content (GH-36) --- CHANGES | 1 + csp/conf/defaults.py | 2 +- csp/conf/deprecation.py | 5 +++++ csp/tests/test_utils.py | 3 ++- csp/utils.py | 7 +++++++ docs/configuration.rst | 4 ++-- 6 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index 9cbb117..f238988 100644 --- a/CHANGES +++ b/CHANGES @@ -17,6 +17,7 @@ Next 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/defaults.py b/csp/conf/defaults.py index 402b543..e47437b 100644 --- a/csp/conf/defaults.py +++ b/csp/conf/defaults.py @@ -36,7 +36,7 @@ 'trusted-types': None, # Other Directives 'upgrade-insecure-requests': False, - 'block-all-mixed-content': False, + 'block-all-mixed-content': False, # Obsolete # Pseudo Directives 'report_only': False, 'include_nonce_in': ('default-src',), diff --git a/csp/conf/deprecation.py b/csp/conf/deprecation.py index fa80f02..2644357 100644 --- a/csp/conf/deprecation.py +++ b/csp/conf/deprecation.py @@ -10,6 +10,11 @@ ) +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.' diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py index 5f796b1..2521e13 100644 --- a/csp/tests/test_utils.py +++ b/csp/tests/test_utils.py @@ -283,7 +283,8 @@ def test_upgrade_insecure_requests(): @override_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) diff --git a/csp/utils.py b/csp/utils.py index 84a9254..49cba79 100644 --- a/csp/utils.py +++ b/csp/utils.py @@ -1,5 +1,6 @@ import copy import re +import warnings from collections import OrderedDict from itertools import chain @@ -167,6 +168,12 @@ def _compile_policy(csp, 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) diff --git a/docs/configuration.rst b/docs/configuration.rst index dcf5ee6..ea5bc3d 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -173,7 +173,7 @@ the deprecation period is over. It will be populated with a single policy under ``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_ @@ -225,4 +225,4 @@ the :ref:`decorator documentation ` for more details. .. _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 From 2628ac4df6e88751aa20a473be18423d3cfcc18f Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Tue, 24 May 2022 23:39:33 -0300 Subject: [PATCH 20/35] fixup! GH-36 Update middleware for multi-policy support --- csp/middleware.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/csp/middleware.py b/csp/middleware.py index cb13333..f85219e 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -70,7 +70,9 @@ def process_response(self, request, response): headers[header].append(csp) for header, policies in headers.items(): - response[header] = '; '.join(policies) + # 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 build_policy(self, request, response): From 16b0e49b25f69bf9f8f6d39738474cf0c7db545c Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Tue, 24 May 2022 23:39:54 -0300 Subject: [PATCH 21/35] fixup! GH-36 Update tests to use new multi-policy format --- csp/tests/settings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/csp/tests/settings.py b/csp/tests/settings.py index f909e10..1f14de1 100644 --- a/csp/tests/settings.py +++ b/csp/tests/settings.py @@ -3,7 +3,9 @@ CSP_POLICY_DEFINITIONS = { 'default': { + 'default-src': ("'self'",), 'report_only': False, + 'include_nonce_in': ('default-src',), }, 'report': { 'report_only': True, From fcc2d6f38923dcc0b689c042043b269e616d24f7 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Tue, 24 May 2022 23:44:18 -0300 Subject: [PATCH 22/35] fixup! GH-36 Add tests for multi-policy support and csp_append/csp_select decorators --- csp/tests/test_decorators.py | 2 +- csp/tests/test_middleware.py | 19 ++++++++++--------- csp/tests/test_utils.py | 19 +++++++++++-------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/csp/tests/test_decorators.py b/csp/tests/test_decorators.py index d962d9f..4fca9b8 100644 --- a/csp/tests/test_decorators.py +++ b/csp/tests/test_decorators.py @@ -224,7 +224,7 @@ def view_with_decorator(request): 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 response[HEADER] == "font-src bar.com, default-src 'self'" assert REPORT_ONLY_HEADER not in response diff --git a/csp/tests/test_middleware.py b/csp/tests/test_middleware.py index 0fa34ea..f5a56e5 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, @@ -40,14 +41,17 @@ def test_exempt(): @override_settings( CSP_POLICIES=('default', 'report'), - CSP_EXCLUDE_URL_PREFIXES=('/inlines-r-us',), ) 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) @@ -107,7 +111,7 @@ def test_use_complex_config(): assert response[REPORT_ONLY_HEADER] == 'img-src test.example.com' -def test_use_order(): +def test_use_select(): request = rf.get('/') response = HttpResponse() response._csp_config = { @@ -124,12 +128,12 @@ def test_use_order(): } response._csp_select = ('child', 'default', 'report_test') mw.process_response(request, response) - policy_list = sorted(response[HEADER].split('; ')) - assert policy_list == ["child-src child.example.com", "default-src 'self'"] + 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_order_dne(): +def test_use_select_dne(): request = rf.get('/') response = HttpResponse() response._csp_select = ('does_not_exist',) @@ -259,9 +263,8 @@ def test_nonce_regenerated_on_new_request(): @override_settings( - CSP_POLICIES=("default", "report"), + CSP_INCLUDE_NONCE_IN=[], ) -@override_settings(CSP_INCLUDE_NONCE_IN=[]) def test_no_nonce_when_disabled_by_settings(): request = rf.get('/') mw.process_request(request) @@ -269,5 +272,3 @@ def test_no_nonce_when_disabled_by_settings(): response = HttpResponse() mw.process_response(request, response) assert nonce not in response[HEADER] - # Legacy settings only apply to default - assert nonce in response[REPORT_ONLY_HEADER] diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py index 2521e13..3243e6b 100644 --- a/csp/tests/test_utils.py +++ b/csp/tests/test_utils.py @@ -20,11 +20,14 @@ def policy_eq( if not isinstance(a, list): b = [(b, report_only, exclude_url_prefixes)] - for csp_a, csp_b in zip(a, b): - assert csp_a[1] == csp_b[1] - assert sorted(csp_a[2]) == sorted(csp_b[2]) - parts_a = sorted(csp_a[0].split('; ')) - parts_b = sorted(csp_b[0].split('; ')) + 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) @@ -301,11 +304,11 @@ 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(): From 76e18947e7fa1b1a8b6f096e9f5926f14e25b358 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Tue, 24 May 2022 23:48:02 -0300 Subject: [PATCH 23/35] GH-36 Disallow mixing deprecated settings with CSP_POLICY_DEFINITIONS --- csp/tests/test_contrib.py | 5 +-- csp/tests/test_decorators.py | 6 +-- csp/tests/test_middleware.py | 10 ++--- csp/tests/test_utils.py | 82 +++++++++++++++++++----------------- csp/tests/utils.py | 14 +++++- csp/utils.py | 2 +- 6 files changed, 67 insertions(+), 52 deletions(-) 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 4fca9b8..77bdb3c 100644 --- a/csp/tests/test_decorators.py +++ b/csp/tests/test_decorators.py @@ -6,7 +6,7 @@ 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 @@ -47,7 +47,7 @@ def view_with_decorator(request): assert response[REPORT_ONLY_HEADER] == "default-src 'self'" -@override_settings(CSP_IMG_SRC=['foo.com']) +@override_legacy_settings(CSP_IMG_SRC=['foo.com']) def test_csp_update(): def view_without_decorator(request): return HttpResponse() @@ -91,7 +91,7 @@ def view_with_decorator(request): assert policy_list == ["default-src 'self'", "font-src foo.com"] -@override_settings(CSP_IMG_SRC=['foo.com']) +@override_legacy_settings(CSP_IMG_SRC=['foo.com']) def test_csp_replace(): def view_without_decorator(request): return HttpResponse() diff --git a/csp/tests/test_middleware.py b/csp/tests/test_middleware.py index f5a56e5..f65209d 100644 --- a/csp/tests/test_middleware.py +++ b/csp/tests/test_middleware.py @@ -10,7 +10,7 @@ import pytest from csp.middleware import CSPMiddleware -from csp.tests.utils import response +from csp.tests.utils import override_legacy_settings, response from csp.utils import HTTP_HEADERS HEADER_SET = set(HTTP_HEADERS) @@ -54,7 +54,7 @@ def test_exclude(): 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() @@ -160,7 +160,7 @@ def test_use_update(): 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() @@ -170,7 +170,7 @@ def test_use_replace(): assert policy_list == ["default-src 'self'", "img-src bar.com"] -@override_settings(CSP_IMG_SRC=['foo.com']) +@override_legacy_settings(CSP_IMG_SRC=['foo.com']) def test_use_complex_replace(): request = rf.get('/') response = HttpResponse() @@ -262,7 +262,7 @@ def test_nonce_regenerated_on_new_request(): assert nonce2 not in response1[header] -@override_settings( +@override_legacy_settings( CSP_INCLUDE_NONCE_IN=[], ) def test_no_nonce_when_disabled_by_settings(): diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py index 3243e6b..88139e7 100644 --- a/csp/tests/test_utils.py +++ b/csp/tests/test_utils.py @@ -1,11 +1,12 @@ from __future__ import absolute_import from django.conf import settings -from django.test.utils import override_settings +from django.test import override_settings from django.utils.functional import lazy import pytest +from csp.tests.utils import override_legacy_settings from csp.utils import ( build_policy, _policies_from_names_and_kwargs, @@ -43,122 +44,122 @@ def literal(s): 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() 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", @@ -171,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) @@ -189,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 @@ -200,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 @@ -210,81 +211,84 @@ 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(): with pytest.warns(DeprecationWarning): policy = build_policy() @@ -296,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'; " 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 49cba79..c0dd312 100644 --- a/csp/utils.py +++ b/csp/utils.py @@ -42,7 +42,7 @@ def get_declared_policy_definitions(): ) deprecation._handle_legacy_settings( custom_definitions['default'], - allow_legacy=True # not hasattr(settings, 'CSP_POLICY_DEFINITIONS'), + allow_legacy=not hasattr(settings, 'CSP_POLICY_DEFINITIONS'), ) definitions = csp_definitions_update( {}, From 7da7eeae0e455066b9e201b0618ad3e1b08870db Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Tue, 24 May 2022 23:56:50 -0300 Subject: [PATCH 24/35] fixup! GH-36 Update tests to use new multi-policy format --- csp/tests/test_decorators.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/csp/tests/test_decorators.py b/csp/tests/test_decorators.py index 77bdb3c..978e108 100644 --- a/csp/tests/test_decorators.py +++ b/csp/tests/test_decorators.py @@ -60,7 +60,7 @@ def view_without_decorator(request): def view_with_decorator(request): return HttpResponse() response = view_with_decorator(REQUEST) - assert dict(response._csp_update) == {'default': {'img-src': ['bar.com']}} + assert response._csp_update == {'default': {'img-src': ['bar.com']}} mw.process_response(REQUEST, response) policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["default-src 'self'", "img-src foo.com bar.com"] @@ -104,7 +104,7 @@ def view_without_decorator(request): def view_with_decorator(request): return HttpResponse() response = view_with_decorator(REQUEST) - assert dict(response._csp_replace) == {'default': {'img-src': ['bar.com']}} + assert response._csp_replace == {'default': {'img-src': ['bar.com']}} mw.process_response(REQUEST, response) policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["default-src 'self'", "img-src bar.com"] @@ -234,7 +234,7 @@ def test_csp_string_values(): def view_with_decorator(request): return HttpResponse() response = view_with_decorator(REQUEST) - assert dict(response._csp_config) == { + assert response._csp_config == { policy_names.last_policy_name: { 'img-src': ['foo.com'], 'font-src': ['bar.com'], From cdd9bb78a760827162a60f46cb176862bc76f434 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Wed, 25 May 2022 00:00:28 -0300 Subject: [PATCH 25/35] fixup! TODO: upgrade test deps --- tox.ini | 2 -- 1 file changed, 2 deletions(-) diff --git a/tox.ini b/tox.ini index f9c8f98..5bbd99d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,5 @@ [tox] envlist = - # {pypy3.9}-4.1.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 @@ -28,7 +27,6 @@ deps= 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 - 4.1.x: https://github.com/django/django/archive/1d071ec1aa8fa414bb96b41f7be8a1bd01079815.tar.gz [testenv:docs] allowlist_externals = make From fb8be3982ad5f8fd3a685e6550cca13a9df06fa5 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Wed, 25 May 2022 00:07:09 -0300 Subject: [PATCH 26/35] fixup! GH-36 Update settings for multi-policy support --- csp/conf/deprecation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csp/conf/deprecation.py b/csp/conf/deprecation.py index 2644357..921503e 100644 --- a/csp/conf/deprecation.py +++ b/csp/conf/deprecation.py @@ -41,7 +41,7 @@ def _handle_legacy_settings(csp, allow_legacy): if not allow_legacy: raise ImproperlyConfigured( - "Settings CSP_POLICY_DEFINITIONS is not allowed with following " + "Setting CSP_POLICY_DEFINITIONS is not allowed with the following " "deprecated settings: %s" % ", ".join(legacy_names) ) From a6685c32c91729221af54bb83c5d5a5047315d60 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Wed, 25 May 2022 00:59:24 -0300 Subject: [PATCH 27/35] fixup! GH-36 Add tests for multi-policy support and csp_append/csp_select decorators --- csp/tests/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py index 88139e7..7fab895 100644 --- a/csp/tests/test_utils.py +++ b/csp/tests/test_utils.py @@ -18,7 +18,7 @@ def policy_eq( ): if not isinstance(a, list): a = [(a, report_only, exclude_url_prefixes)] - if not isinstance(a, list): + if not isinstance(b, list): b = [(b, report_only, exclude_url_prefixes)] for ( From a21895684620e29c36a7e837413c6662c2e0519f Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Thu, 26 May 2022 00:05:16 -0300 Subject: [PATCH 28/35] Refactor CSPMiddleware for extensibility (GH-36) --- csp/contrib/rate_limiting.py | 18 +++++------------- csp/middleware.py | 9 ++++++--- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/csp/contrib/rate_limiting.py b/csp/contrib/rate_limiting.py index 724ae54..c479bb3 100644 --- a/csp/contrib/rate_limiting.py +++ b/csp/contrib/rate_limiting.py @@ -3,28 +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): - build_kwargs = { - key: getattr(response, '_csp_%s' % key, None) - for key in ('config', 'update', 'select') - } - 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( - replace=replace, - nonce=nonce, - **build_kwargs, - ) + return build_kwargs diff --git a/csp/middleware.py b/csp/middleware.py index f85219e..1f36078 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -75,12 +75,15 @@ def process_response(self, request, response): response[header] = ', '.join(policies) return response - def build_policy(self, request, 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): return build_policy( - nonce=getattr(request, '_csp_nonce', None), - **build_kwargs, + **self.get_build_kwargs(request, response), ) From 22f73013dda4dfd28d81d1239b373ef4526afbea Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Thu, 26 May 2022 02:15:18 -0300 Subject: [PATCH 29/35] fixup! GH-36 Update settings for multi-policy support --- csp/conf/__init__.py | 75 +++++++++++++++++++++++++++++------------ csp/conf/defaults.py | 3 ++ csp/conf/deprecation.py | 26 ++++++++++---- 3 files changed, 76 insertions(+), 28 deletions(-) diff --git a/csp/conf/__init__.py b/csp/conf/__init__.py index aaf64c8..c407682 100644 --- a/csp/conf/__init__.py +++ b/csp/conf/__init__.py @@ -1,26 +1,57 @@ -from . import defaults - - -DIRECTIVES = set(defaults.POLICY) -PSEUDO_DIRECTIVES = {d for d in DIRECTIVES if '_' in d} - - -def setting_to_directive(setting, value, prefix='CSP_'): - setting = setting[len(prefix):].lower() - if setting not in PSEUDO_DIRECTIVES: - setting = setting.replace('_', '-') - assert setting in DIRECTIVES - if isinstance(value, str): - value = [value] - return setting, value +__all__ = [ + 'defaults', + 'deprecation', + 'directive_to_setting', + 'get_declared_policies', + 'get_declared_policy_definitions', + 'setting_to_directive', + 'DIRECTIVES', +] + +from django.conf import settings - -def directive_to_setting(directive, prefix='CSP_'): - setting = '{}{}'.format( - prefix, - directive.replace('-', '_').upper() +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} ) - return setting + for name, csp in custom_definitions.items(): + definitions.setdefault(name, {}).update(csp) + return definitions -LEGACY_KWARGS = {directive_to_setting(d, prefix='') for d in DIRECTIVES} +def get_declared_policies(): + return getattr(settings, 'CSP_POLICIES', defaults.POLICIES) diff --git a/csp/conf/defaults.py b/csp/conf/defaults.py index e47437b..b918ecb 100644 --- a/csp/conf/defaults.py +++ b/csp/conf/defaults.py @@ -42,3 +42,6 @@ '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 index 921503e..2e9d539 100644 --- a/csp/conf/deprecation.py +++ b/csp/conf/deprecation.py @@ -3,11 +3,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from . import ( - setting_to_directive, - directive_to_setting, - DIRECTIVES, -) +from . import defaults BLOCK_ALL_MIXED_CONTENT_DEPRECATION_WARNING = ( @@ -21,8 +17,26 @@ ) +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 DIRECTIVES + directive_to_setting(directive) for directive in defaults.DIRECTIVES } From eb66ddcfff4cfad51fad44e0a764e9c336ae9f6b Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Thu, 26 May 2022 02:15:47 -0300 Subject: [PATCH 30/35] fixup! GH-36 Add tests for multi-policy support and csp_append/csp_select decorators --- csp/tests/test_decorators.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/csp/tests/test_decorators.py b/csp/tests/test_decorators.py index 978e108..4b5dcb3 100644 --- a/csp/tests/test_decorators.py +++ b/csp/tests/test_decorators.py @@ -170,6 +170,22 @@ def view_with_decorator(request): 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']}, From 3d157cc5200c31dcdd5ea2234dad4a2fac744352 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Thu, 26 May 2022 02:15:47 -0300 Subject: [PATCH 31/35] fixup! TEMP: Add utils tests --- csp/tests/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py index 7fab895..afdb868 100644 --- a/csp/tests/test_utils.py +++ b/csp/tests/test_utils.py @@ -316,5 +316,5 @@ def test_nonce_include_in_absent(): def test_policies_from_names_and_kwargs(): - with pytest.raises(ValueError): + with pytest.raises(TypeError): _policies_from_names_and_kwargs(None, {}) From cf88db50626db84251de881c42403e443baba0ea Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Thu, 26 May 2022 02:17:02 -0300 Subject: [PATCH 32/35] fixup! GH-36 Update csp decorators for multi-policy support --- csp/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/csp/utils.py b/csp/utils.py index c0dd312..a40a72a 100644 --- a/csp/utils.py +++ b/csp/utils.py @@ -5,7 +5,6 @@ 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 From 0a947e296629524cffdfb252c3c8fe28378d5087 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Thu, 26 May 2022 02:17:02 -0300 Subject: [PATCH 33/35] fixup! GH-36 Update utils to handle multiple policies --- csp/utils.py | 94 +++++++++++++++++++--------------------------------- 1 file changed, 35 insertions(+), 59 deletions(-) diff --git a/csp/utils.py b/csp/utils.py index a40a72a..54cc816 100644 --- a/csp/utils.py +++ b/csp/utils.py @@ -13,8 +13,11 @@ from .conf import ( defaults, deprecation, + directive_to_setting, + get_declared_policies, + get_declared_policy_definitions, setting_to_directive, - LEGACY_KWARGS, + DIRECTIVES, ) @@ -194,15 +197,6 @@ def kwarg_to_directive(kwarg, value=None): return setting_to_directive(kwarg, prefix='', value=value) -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 - - class PolicyNames: length = 20 last_policy_name = None @@ -221,75 +215,57 @@ def __iter__(self): def _clean_input_policy(policy): return dict( kwarg_to_directive(in_directive, value=value) - if in_directive.isupper() else (in_directive, value) for in_directive, value in policy.items() ) -def iter_policies(policies, name_generator=policy_names): - """ - Accepts the following formats: - - a policy dictionary (formatted like in settings.CSP_POLICY_DEFINITIONS) - - an iterable of policies: (item, item, item,...) - - item can be any of the following: - - subscriptable two-tuple: (name, csp) - - csp dictionary which will be assigned a random name - - Yields a tuple: (name, csp) - """ - if isinstance(policies, dict): - yield from ( - (name, _clean_input_policy(policy)) - for name, policy in policies.items() - ) - return - - for policy in policies: - if isinstance(policy, (list, tuple)): - yield (policy[0], _clean_input_policy(policy[1])) - else: # dictionary containing a single csp - yield (next(name_generator), _clean_input_policy(policy)) - - -def _kwargs_are_directives(kwargs): - keys = set(kwargs) - if keys.intersection(LEGACY_KWARGS): # Legacy settings +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?) - if keys.difference(LEGACY_KWARGS): - raise ValueError( - "If legacy settings are passed to the csp decorator, all " - "kwargs must be legacy settings." + 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 False + return { + name: _clean_input_policy(kwargs) + for name in name_generator + } # else: a dictionary of named policies - return True + return kwargs def _policies_from_names_and_kwargs(csp_names, kwargs): """ Helper used in csp_update and csp_replace to process args """ - if kwargs: - if not _kwargs_are_directives(kwargs): - policy = _clean_input_policy(kwargs) - return {name: policy for name in csp_names} - return dict(iter_policies(kwargs)) - else: - raise ValueError("kwargs must not be empty.") + 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 = [] + all_definitions = {} if args: # A list of policy dictionaries - all_definitions.append(iter_policies(args)) + all_definitions.update({ + next(policy_names): policy + for policy in args + }) if kwargs: - if not _kwargs_are_directives(kwargs): - kwargs = [kwargs] - all_definitions.append(iter_policies(kwargs)) + all_definitions.update( + _kwargs_to_directives( + kwargs, + (next(policy_names) for _ in range(1)) + ), + ) - return dict(chain(*all_definitions)) + return all_definitions def _default_attr_mapper(attr_name, val): From 0b1b649c28df043f1c69b8f97e701f29e225522b Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Thu, 26 May 2022 02:17:02 -0300 Subject: [PATCH 34/35] fixup! GH-36 Disallow mixing deprecated settings with CSP_POLICY_DEFINITIONS --- csp/utils.py | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/csp/utils.py b/csp/utils.py index 54cc816..bfb54f9 100644 --- a/csp/utils.py +++ b/csp/utils.py @@ -32,31 +32,7 @@ http_client.NOT_FOUND, } - -def get_declared_policy_definitions(): - custom_definitions = csp_definitions_update( - {}, - getattr( - settings, - 'CSP_POLICY_DEFINITIONS', - {'default': {}}, - ), - ) - deprecation._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) +SINGLE_POLICY_KWARGS = {directive_to_setting(d, prefix='') for d in DIRECTIVES} def _normalize_config(config, key='default'): From 1cc55a30cf70ff26a09993b678317dfa8d9662e6 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Thu, 26 May 2022 02:18:30 -0300 Subject: [PATCH 35/35] fixup! GH-36 Update docs for multi-policy support --- docs/configuration.rst | 25 ++++-- docs/decorators.rst | 181 ++++++++++++++++++++++++++++++----------- 2 files changed, 152 insertions(+), 54 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index ea5bc3d..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``. @@ -32,15 +42,20 @@ These settings affect the policy in the header. The defaults are in *italics*. `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 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. + 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: diff --git a/docs/decorators.rst b/docs/decorators.rst index 75809c5..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,105 +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. + +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(...) - default-src 'self'; img-src imgsrv.com -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`` :: 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_select`` -=============== + @csp_replace(default={'img-src': 'imgsrv2.com'}) + def myview(request): + return render(...) -The ``@csp_select`` decorator allows you to select policies to include -from the current policy definitions being applied. -It accepts a mixed iterable of names or indices into the compiled definitions. +.. _csp-decorator: -It acts very much like the ``CSP_POLICIES`` setting except that it can use -indices, which don't work for ``CSP_POLICIES`` because it's used to define -the order on the compiled policy in the first place). +``@csp`` +======== + +If you need to replace the entire policy list on a view, ignoring all the +settings, you can use the ``@csp`` decorator. -NOTE: in the case of passing a ``dict`` to one of the other decorators, -the order will not be well-defined before Python 3.6. -Avoid using indices in this cases. +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). -For named policies it will fallback to ``CSP_POLICY_DEFINITIONS`` even if they -don't appear in the current policy, so use with care +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_select + from csp.decorators import csp - # Using default settings - # Will first apply the default policy, then the second policy, then the first policy - @csp_select(['default', 1, 0]) # or @csp_select(['default', 'second', 'first]) - @csp(csp_definitions=( - ('first', {'default-src': ["'self'"], 'img-src': ['imgsrv.com']}), - ('second', {'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`` -======== + @csp(new={ + default-src=["'self'"], + img-src=['imgsrv.com'], + script-src=['scriptsrv.com', 'googleanalytics.com'], + }) + def myview(request): + return render(...) + + # OR -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 + @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 + from csp.decorators import csp_append - @csp(DEFAULT_SRC=["'self'"], IMG_SRC=['imgsrv.com'], - SCRIPT_SRC=['scriptsrv.com', 'googleanalytics.com']) + # 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(...)