From 3413de318a8a56725d5b68faecd2fc9c0d162097 Mon Sep 17 00:00:00 2001 From: Rob Hudson Date: Wed, 1 May 2024 12:58:56 -0700 Subject: [PATCH] Fixes #36: Move to dictionary based settings This is a backwards incompatible change. Also fixes #139, #191 --- .coveragerc | 7 + .gitignore | 1 + CHANGES | 13 +- csp/apps.py | 11 ++ csp/checks.py | 81 ++++++++++ csp/constants.py | 2 + csp/contrib/rate_limiting.py | 27 +++- csp/decorators.py | 79 +++++++--- csp/middleware.py | 50 +++--- csp/tests/settings.py | 15 +- csp/tests/test_checks.py | 52 ++++++ csp/tests/test_contrib.py | 33 +++- csp/tests/test_decorators.py | 274 ++++++++++++++++++++++++++------ csp/tests/test_middleware.py | 25 ++- csp/tests/test_utils.py | 126 +++++++++------ csp/utils.py | 115 ++++++++------ docs/configuration.rst | 296 +++++++++++++++++++++-------------- docs/contributing.rst | 4 + docs/decorators.rst | 80 ++++++---- docs/index.rst | 1 + docs/installation.rst | 27 ++-- docs/migration-guide.rst | 172 ++++++++++++++++++++ docs/nonce.rst | 77 +++++---- docs/reports.rst | 27 ++-- docs/trusted_types.rst | 92 ++++++----- tox.ini | 1 - 26 files changed, 1228 insertions(+), 460 deletions(-) create mode 100644 .coveragerc create mode 100644 csp/apps.py create mode 100644 csp/checks.py create mode 100644 csp/constants.py create mode 100644 csp/tests/test_checks.py create mode 100644 docs/migration-guide.rst diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..90da0c0 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,7 @@ +[run] +source = csp +omit = + csp/tests/* + +[report] +show_missing = True diff --git a/.gitignore b/.gitignore index 90f56c0..63d548a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ .tox dist build +docs/_build diff --git a/CHANGES b/CHANGES index 4a45f50..01eeb62 100644 --- a/CHANGES +++ b/CHANGES @@ -2,10 +2,17 @@ CHANGES ======= -Unreleased -========== +4.x - Unreleased +================ -- Add pyproject-fmt to pre-commit, and update pre-commit versions. +BACKWARDS INCOMPATIBLE changes: +- Move to dict-based configuration which allows for setting policies for both enforced and + report-only. See the migration guide in the docs for migrating your settings. + +Other changes: +- Add pyproject-fmt to pre-commit, and update pre-commit versions +- Fixes #36: Add support for enforced and report-only policies simultaneously +- Drop support for Django <=3.2, end of extended support 3.8 === diff --git a/csp/apps.py b/csp/apps.py new file mode 100644 index 0000000..caf9f45 --- /dev/null +++ b/csp/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig +from django.core import checks + +from csp.checks import check_django_csp_lt_4_0 + + +class CspConfig(AppConfig): + name = "csp" + + def ready(self): + checks.register(check_django_csp_lt_4_0, checks.Tags.security) diff --git a/csp/checks.py b/csp/checks.py new file mode 100644 index 0000000..23c76f0 --- /dev/null +++ b/csp/checks.py @@ -0,0 +1,81 @@ +import pprint + +from django.conf import settings +from django.core.checks import Error + + +OUTDATED_SETTINGS = [ + "CSP_CHILD_SRC", + "CSP_CONNECT_SRC", + "CSP_DEFAULT_SRC", + "CSP_SCRIPT_SRC", + "CSP_SCRIPT_SRC_ATTR", + "CSP_SCRIPT_SRC_ELEM", + "CSP_OBJECT_SRC", + "CSP_STYLE_SRC", + "CSP_STYLE_SRC_ATTR", + "CSP_STYLE_SRC_ELEM", + "CSP_FONT_SRC", + "CSP_FRAME_SRC", + "CSP_IMG_SRC", + "CSP_MANIFEST_SRC", + "CSP_MEDIA_SRC", + "CSP_PREFETCH_SRC", + "CSP_WORKER_SRC", + "CSP_BASE_URI", + "CSP_PLUGIN_TYPES", + "CSP_SANDBOX", + "CSP_FORM_ACTION", + "CSP_FRAME_ANCESTORS", + "CSP_NAVIGATE_TO", + "CSP_REQUIRE_SRI_FOR", + "CSP_REQUIRE_TRUSTED_TYPES_FOR", + "CSP_TRUSTED_TYPES", + "CSP_UPGRADE_INSECURE_REQUESTS", + "CSP_BLOCK_ALL_MIXED_CONTENT", + "CSP_REPORT_URI", + "CSP_REPORT_TO", + "CSP_INCLUDE_NONCE_IN", +] + + +def migrate_settings(): + # This function is used to migrate settings from the old format to the new format. + config = { + "DIRECTIVES": {}, + } + REPORT_ONLY = False + + if hasattr(settings, "CSP_REPORT_ONLY"): + REPORT_ONLY = settings.CSP_REPORT_ONLY + + if hasattr(settings, "CSP_EXCLUDE_URL_PREFIXES"): + config["EXCLUDE_URL_PREFIXES"] = settings.CSP_EXCLUDE_URL_PREFIXES + + if hasattr(settings, "CSP_REPORT_PERCENTAGE"): + config["REPORT_PERCENTAGE"] = round(settings.CSP_REPORT_PERCENTAGE * 100) + + for setting in OUTDATED_SETTINGS: + if hasattr(settings, setting): + directive = setting[4:].replace("_", "-").lower() + value = getattr(settings, setting) + if value: + config["DIRECTIVES"][directive] = value + + return config, REPORT_ONLY + + +def check_django_csp_lt_4_0(app_configs, **kwargs): + check_settings = OUTDATED_SETTINGS + ["CSP_REPORT_ONLY", "CSP_EXCLUDE_URL_PREFIXES", "CSP_REPORT_PERCENTAGE"] + if any(hasattr(settings, setting) for setting in check_settings): + # Try to build the new config. + config, REPORT_ONLY = migrate_settings() + warning = ( + "You are using django-csp < 4.0 settings. Please update your settings to use the new format.\n" + "See https://django-csp.readthedocs.io/en/latest/migration-guide.html for more information.\n\n" + "We have attempted to build the new CSP config for you based on your current settings:\n\n" + f"CONTENT_SECURITY_POLICY{'_REPORT_ONLY' if REPORT_ONLY else ''} = " + pprint.pformat(config, sort_dicts=True) + ) + return [Error(warning, id="csp.E001")] + + return [] diff --git a/csp/constants.py b/csp/constants.py new file mode 100644 index 0000000..8cf10bc --- /dev/null +++ b/csp/constants.py @@ -0,0 +1,2 @@ +HEADER = "Content-Security-Policy" +HEADER_REPORT_ONLY = "Content-Security-Policy-Report-Only" diff --git a/csp/contrib/rate_limiting.py b/csp/contrib/rate_limiting.py index 2419c67..4cc4f90 100644 --- a/csp/contrib/rate_limiting.py +++ b/csp/contrib/rate_limiting.py @@ -16,9 +16,32 @@ def build_policy(self, request, response): replace = getattr(response, "_csp_replace", {}) nonce = getattr(request, "_csp_nonce", None) - report_percentage = getattr(settings, "CSP_REPORT_PERCENTAGE") - include_report_uri = random.random() < report_percentage + policy = getattr(settings, "CONTENT_SECURITY_POLICY", None) + + if policy is None: + return "" + + report_percentage = policy.get("REPORT_PERCENTAGE", 100) + include_report_uri = random.randint(0, 100) < report_percentage if not include_report_uri: replace["report-uri"] = None return build_policy(config=config, update=update, replace=replace, nonce=nonce) + + def build_policy_ro(self, request, response): + config = getattr(response, "_csp_config_ro", None) + update = getattr(response, "_csp_update_ro", None) + replace = getattr(response, "_csp_replace_ro", {}) + nonce = getattr(request, "_csp_nonce", None) + + policy = getattr(settings, "CONTENT_SECURITY_POLICY_REPORT_ONLY", None) + + if policy is None: + return "" + + report_percentage = policy.get("REPORT_PERCENTAGE", 100) + include_report_uri = random.randint(0, 100) < report_percentage + if not include_report_uri: + replace["report-uri"] = None + + return build_policy(config=config, update=update, replace=replace, nonce=nonce, report_only=True) diff --git a/csp/decorators.py b/csp/decorators.py index 69d0f85..3d71b6c 100644 --- a/csp/decorators.py +++ b/csp/decorators.py @@ -1,55 +1,90 @@ from functools import wraps -def csp_exempt(f): - @wraps(f) - def _wrapped(*a, **kw): - r = f(*a, **kw) - r._csp_exempt = True - return r +def csp_exempt(REPORT_ONLY=None): + if callable(REPORT_ONLY): + raise RuntimeError( + "Incompatible `csp_exempt` decorator usage. This decorator now requires arguments, " + "even if none are passed. Change bare decorator usage (@csp_exempt) to parameterized " + "decorator usage (@csp_exempt()). See the django-csp 4.0 migration guide for more " + "information." + ) - return _wrapped + def decorator(f): + @wraps(f) + def _wrapped(*a, **kw): + resp = f(*a, **kw) + if REPORT_ONLY: + resp._csp_exempt_ro = True + else: + resp._csp_exempt = True + return resp + + return _wrapped + return decorator + + +# Error message for deprecated decorator arguments. +DECORATOR_DEPRECATION_ERROR = ( + "Incompatible `{fname}` decorator arguments. This decorator now takes a single dict argument. " + "See the django-csp 4.0 migration guide for more information." +) -def csp_update(**kwargs): - update = {k.lower().replace("_", "-"): v for k, v in kwargs.items()} + +def csp_update(config=None, REPORT_ONLY=False, **kwargs): + if config is None and kwargs: + raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp_update")) def decorator(f): @wraps(f) def _wrapped(*a, **kw): - r = f(*a, **kw) - r._csp_update = update - return r + resp = f(*a, **kw) + if REPORT_ONLY: + resp._csp_update_ro = config + else: + resp._csp_update = config + return resp return _wrapped return decorator -def csp_replace(**kwargs): - replace = {k.lower().replace("_", "-"): v for k, v in kwargs.items()} +def csp_replace(config=None, REPORT_ONLY=False, **kwargs): + if config is None and kwargs: + raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp_replace")) def decorator(f): @wraps(f) def _wrapped(*a, **kw): - r = f(*a, **kw) - r._csp_replace = replace - return r + resp = f(*a, **kw) + if REPORT_ONLY: + resp._csp_replace_ro = config + else: + resp._csp_replace = config + return resp return _wrapped return decorator -def csp(**kwargs): - config = {k.lower().replace("_", "-"): [v] if isinstance(v, str) else v for k, v in kwargs.items()} +def csp(config=None, REPORT_ONLY=False, **kwargs): + if config is None and kwargs: + raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp")) + + config = {k: [v] if isinstance(v, str) else v for k, v in config.items()} def decorator(f): @wraps(f) def _wrapped(*a, **kw): - r = f(*a, **kw) - r._csp_config = config - return r + resp = f(*a, **kw) + if REPORT_ONLY: + resp._csp_config_ro = config + else: + resp._csp_config = config + return resp return _wrapped diff --git a/csp/middleware.py b/csp/middleware.py index 35465d9..11bf799 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -7,6 +7,7 @@ from django.utils.deprecation import MiddlewareMixin from django.utils.functional import SimpleLazyObject +from csp.constants import HEADER, HEADER_REPORT_ONLY from csp.utils import build_policy @@ -21,8 +22,7 @@ class CSPMiddleware(MiddlewareMixin): """ def _make_nonce(self, request): - # Ensure that any subsequent calls to request.csp_nonce return the - # same value + # Ensure that any subsequent calls to request.csp_nonce return the same value if not getattr(request, "_csp_nonce", None): request._csp_nonce = base64.b64encode(os.urandom(16)).decode("ascii") return request._csp_nonce @@ -32,32 +32,33 @@ def process_request(self, request): request.csp_nonce = SimpleLazyObject(nonce) def process_response(self, request, response): - if getattr(response, "_csp_exempt", False): - return response - - # Check for ignored path prefix. - prefixes = getattr(settings, "CSP_EXCLUDE_URL_PREFIXES", ()) - if request.path_info.startswith(prefixes): - return response - # Check for debug view - status_code = response.status_code exempted_debug_codes = ( http_client.INTERNAL_SERVER_ERROR, http_client.NOT_FOUND, ) - if status_code in exempted_debug_codes and settings.DEBUG: + if response.status_code in exempted_debug_codes and settings.DEBUG: return response - header = "Content-Security-Policy" - if getattr(settings, "CSP_REPORT_ONLY", False): - header += "-Report-Only" - - if header in response: - # Don't overwrite existing headers. - return response - - response[header] = self.build_policy(request, response) + csp = self.build_policy(request, response) + if csp: + # Only set header if not already set and not an excluded prefix and not exempted. + is_not_exempt = getattr(response, "_csp_exempt", False) is False + no_header = HEADER not in response + prefixes = getattr(settings, "CONTENT_SECURITY_POLICY", {}).get("EXCLUDE_URL_PREFIXES", ()) + is_not_excluded = not request.path_info.startswith(prefixes) + if all((no_header, is_not_exempt, is_not_excluded)): + response[HEADER] = csp + + csp_ro = self.build_policy_ro(request, response) + if csp_ro: + # Only set header if not already set and not an excluded prefix and not exempted. + is_not_exempt = getattr(response, "_csp_exempt_ro", False) is False + no_header = HEADER_REPORT_ONLY not in response + prefixes = getattr(settings, "CONTENT_SECURITY_POLICY_REPORT_ONLY", {}).get("EXCLUDE_URL_PREFIXES", ()) + is_not_excluded = not request.path_info.startswith(prefixes) + if all((no_header, is_not_exempt, is_not_excluded)): + response[HEADER_REPORT_ONLY] = csp_ro return response @@ -67,3 +68,10 @@ def build_policy(self, request, response): replace = getattr(response, "_csp_replace", None) nonce = getattr(request, "_csp_nonce", None) return build_policy(config=config, update=update, replace=replace, nonce=nonce) + + def build_policy_ro(self, request, response): + config = getattr(response, "_csp_config_ro", None) + update = getattr(response, "_csp_update_ro", None) + replace = getattr(response, "_csp_replace_ro", None) + nonce = getattr(request, "_csp_nonce", None) + return build_policy(config=config, update=update, replace=replace, nonce=nonce, report_only=True) diff --git a/csp/tests/settings.py b/csp/tests/settings.py index 7c97a1d..08b51a8 100644 --- a/csp/tests/settings.py +++ b/csp/tests/settings.py @@ -1,8 +1,8 @@ -import django - -CSP_REPORT_ONLY = False - -CSP_INCLUDE_NONCE_IN = ["default-src"] +CONTENT_SECURITY_POLICY = { + "DIRECTIVES": { + "include-nonce-in": ["default-src"], + } +} DATABASES = { "default": { @@ -39,8 +39,3 @@ "OPTIONS": {}, }, ] - - -# Django >1.6 requires `setup` call to initialise apps framework -if hasattr(django, "setup"): - django.setup() diff --git a/csp/tests/test_checks.py b/csp/tests/test_checks.py new file mode 100644 index 0000000..b3f6fb0 --- /dev/null +++ b/csp/tests/test_checks.py @@ -0,0 +1,52 @@ +from django.test.utils import override_settings + +from csp.checks import check_django_csp_lt_4_0, migrate_settings + + +@override_settings( + CSP_REPORT_PERCENTAGE=0.25, + CSP_EXCLUDE_URL_PREFIXES=["/admin/"], + CSP_REPORT_ONLY=False, + CSP_DEFAULT_SRC=["'self'", "example.com"], +) +def test_migrate_settings(): + config, report_only = migrate_settings() + assert config == { + "REPORT_PERCENTAGE": 25, + "EXCLUDE_URL_PREFIXES": ["/admin/"], + "DIRECTIVES": {"default-src": ["'self'", "example.com"]}, + } + assert report_only is False + + +@override_settings( + CSP_REPORT_ONLY=True, + CSP_DEFAULT_SRC=["'self'", "example.com"], + CSP_SCRIPT_SRC=["'self'", "example.com", "'unsafe-inline'"], + CSP_INCLUDE_NONCE_IN=["script-src"], +) +def test_migrate_settings_report_only(): + config, report_only = migrate_settings() + assert config == { + "DIRECTIVES": { + "default-src": ["'self'", "example.com"], + "script-src": ["'self'", "example.com", "'unsafe-inline'"], + "include-nonce-in": ["script-src"], + } + } + assert report_only is True + + +@override_settings( + CSP_DEFAULT_SRC=["'self'", "example.com"], +) +def test_check_django_csp_lt_4_0(): + errors = check_django_csp_lt_4_0(None) + assert len(errors) == 1 + error = errors[0] + assert error.id == "csp.E001" + assert "update your settings to use the new format" in error.msg + + +def test_check_django_csp_lt_4_0_no_config(): + assert check_django_csp_lt_4_0(None) == [] diff --git a/csp/tests/test_contrib.py b/csp/tests/test_contrib.py index 6b7bbc0..5c16fec 100644 --- a/csp/tests/test_contrib.py +++ b/csp/tests/test_contrib.py @@ -2,15 +2,15 @@ from django.test import RequestFactory from django.test.utils import override_settings +from csp.constants import HEADER, HEADER_REPORT_ONLY from csp.contrib.rate_limiting import RateLimitedCSPMiddleware from csp.tests.utils import response -HEADER = "Content-Security-Policy" mw = RateLimitedCSPMiddleware(response()) rf = RequestFactory() -@override_settings(CSP_REPORT_PERCENTAGE=0.1, CSP_REPORT_URI="x") +@override_settings(CONTENT_SECURITY_POLICY={"REPORT_PERCENTAGE": 10, "DIRECTIVES": {"report-uri": "x"}}) def test_report_percentage(): times_seen = 0 for _ in range(5000): @@ -21,3 +21,32 @@ def test_report_percentage(): times_seen += 1 # Roughly 10% assert 400 <= times_seen <= 600 + + +@override_settings(CONTENT_SECURITY_POLICY_REPORT_ONLY={"REPORT_PERCENTAGE": 10, "DIRECTIVES": {"report-uri": "x"}}) +def test_report_percentage_report_only(): + times_seen = 0 + for _ in range(5000): + request = rf.get("/") + response = HttpResponse() + mw.process_response(request, response) + if "report-uri" in response[HEADER_REPORT_ONLY]: + times_seen += 1 + # Roughly 10% + assert 400 <= times_seen <= 600 + + +@override_settings(CONTENT_SECURITY_POLICY=None) +def test_no_csp(): + request = rf.get("/") + response = HttpResponse() + mw.process_response(request, response) + assert HEADER not in response + + +@override_settings(CONTENT_SECURITY_POLICY_REPORT_ONLY=None) +def test_no_csp_ro(): + request = rf.get("/") + response = HttpResponse() + mw.process_response(request, response) + assert HEADER_REPORT_ONLY not in response diff --git a/csp/tests/test_decorators.py b/csp/tests/test_decorators.py index a877bc4..45e7e93 100644 --- a/csp/tests/test_decorators.py +++ b/csp/tests/test_decorators.py @@ -1,118 +1,298 @@ +import pytest from django.http import HttpResponse from django.test import RequestFactory from django.test.utils import override_settings +from csp.constants import HEADER, HEADER_REPORT_ONLY from csp.decorators import csp, csp_exempt, csp_replace, csp_update from csp.middleware import CSPMiddleware from csp.tests.utils import response -REQUEST = RequestFactory().get("/") mw = CSPMiddleware(response()) def test_csp_exempt(): - @csp_exempt + @csp_exempt() def view(request): return HttpResponse() - response = view(REQUEST) - assert response._csp_exempt + response = view(RequestFactory().get("/")) + assert response._csp_exempt is True + assert not hasattr(response, "_csp_exempt_ro") -@override_settings(CSP_IMG_SRC=["foo.com"]) +def test_csp_exempt_ro(): + @csp_exempt(REPORT_ONLY=True) + def view(request): + return HttpResponse() + + response = view(RequestFactory().get("/")) + assert not hasattr(response, "_csp_exempt") + assert response._csp_exempt_ro is True + + +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["foo.com"]}}) def test_csp_update(): + request = RequestFactory().get("/") + + def view_without_decorator(request): + return HttpResponse() + + response = view_without_decorator(request) + mw.process_response(request, response) + assert HEADER_REPORT_ONLY not in response.headers + policy_list = sorted(response[HEADER].split("; ")) + assert policy_list == ["default-src 'self'", "img-src foo.com"] + + @csp_update({"img-src": ["bar.com"], "include-nonce-in": ["img-src"]}) + def view_with_decorator(request): + return HttpResponse() + + response = view_with_decorator(request) + assert response._csp_update == {"img-src": ["bar.com"], "include-nonce-in": ["img-src"]} + mw.process_request(request) + assert request.csp_nonce # Here to trigger the nonce creation. + mw.process_response(request, response) + assert HEADER_REPORT_ONLY not in response.headers + policy_list = sorted(response[HEADER].split("; ")) + assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{request.csp_nonce}'"] + + response = view_without_decorator(request) + mw.process_response(request, response) + assert HEADER_REPORT_ONLY not in response.headers + policy_list = sorted(response[HEADER].split("; ")) + assert policy_list == ["default-src 'self'", "img-src foo.com"] + + +@override_settings(CONTENT_SECURITY_POLICY=None, CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"img-src": ["foo.com"]}}) +def test_csp_update_ro(): + request = RequestFactory().get("/") + 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("; ")) + response = view_without_decorator(request) + mw.process_response(request, response) + assert HEADER not in response.headers + policy_list = sorted(response[HEADER_REPORT_ONLY].split("; ")) assert policy_list == ["default-src 'self'", "img-src foo.com"] - @csp_update(IMG_SRC="bar.com") + @csp_update({"img-src": ["bar.com"], "include-nonce-in": ["img-src"]}, REPORT_ONLY=True) def view_with_decorator(request): return HttpResponse() - response = view_with_decorator(REQUEST) - assert response._csp_update == {"img-src": "bar.com"} - mw.process_response(REQUEST, response) - policy_list = sorted(response["Content-Security-Policy"].split("; ")) - assert policy_list == ["default-src 'self'", "img-src foo.com bar.com"] + response = view_with_decorator(request) + assert response._csp_update_ro == {"img-src": ["bar.com"], "include-nonce-in": ["img-src"]} + mw.process_request(request) + assert request.csp_nonce # Here to trigger the nonce creation. + mw.process_response(request, response) + assert HEADER not in response.headers + policy_list = sorted(response[HEADER_REPORT_ONLY].split("; ")) + assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{request.csp_nonce}'"] - response = view_without_decorator(REQUEST) - mw.process_response(REQUEST, response) - policy_list = sorted(response["Content-Security-Policy"].split("; ")) + response = view_without_decorator(request) + mw.process_response(request, response) + assert HEADER not in response.headers + policy_list = sorted(response[HEADER_REPORT_ONLY].split("; ")) assert policy_list == ["default-src 'self'", "img-src foo.com"] -@override_settings(CSP_IMG_SRC=["foo.com"]) +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["foo.com"]}}) def test_csp_replace(): + request = RequestFactory().get("/") + 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("; ")) + response = view_without_decorator(request) + mw.process_response(request, response) + assert HEADER_REPORT_ONLY not in response.headers + policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["default-src 'self'", "img-src foo.com"] - @csp_replace(IMG_SRC="bar.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"} - mw.process_response(REQUEST, response) - policy_list = sorted(response["Content-Security-Policy"].split("; ")) + response = view_with_decorator(request) + assert response._csp_replace == {"img-src": ["bar.com"]} + mw.process_response(request, response) + assert HEADER_REPORT_ONLY not in response.headers + 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("; ")) + response = view_without_decorator(request) + mw.process_response(request, response) + assert HEADER_REPORT_ONLY not in response.headers + policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["default-src 'self'", "img-src foo.com"] - @csp_replace(IMG_SRC=None) + @csp_replace({"img-src": None}) 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("; ")) + response = view_removing_directive(request) + mw.process_response(request, response) + assert HEADER_REPORT_ONLY not in response.headers + policy_list = sorted(response[HEADER].split("; ")) + assert policy_list == ["default-src 'self'"] + + +@override_settings(CONTENT_SECURITY_POLICY=None, CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"img-src": ["foo.com"]}}) +def test_csp_replace_ro(): + request = RequestFactory().get("/") + + def view_without_decorator(request): + return HttpResponse() + + response = view_without_decorator(request) + mw.process_response(request, response) + assert HEADER not in response.headers + policy_list = sorted(response[HEADER_REPORT_ONLY].split("; ")) + assert policy_list == ["default-src 'self'", "img-src foo.com"] + + @csp_replace({"img-src": ["bar.com"]}, REPORT_ONLY=True) + def view_with_decorator(request): + return HttpResponse() + + response = view_with_decorator(request) + assert response._csp_replace_ro == {"img-src": ["bar.com"]} + mw.process_response(request, response) + assert HEADER not in response.headers + policy_list = sorted(response[HEADER_REPORT_ONLY].split("; ")) + assert policy_list == ["default-src 'self'", "img-src bar.com"] + + response = view_without_decorator(request) + mw.process_response(request, response) + assert HEADER not in response.headers + policy_list = sorted(response[HEADER_REPORT_ONLY].split("; ")) + assert policy_list == ["default-src 'self'", "img-src foo.com"] + + @csp_replace({"img-src": None}, REPORT_ONLY=True) + def view_removing_directive(request): + return HttpResponse() + + response = view_removing_directive(request) + mw.process_response(request, response) + assert HEADER not in response.headers + policy_list = sorted(response[HEADER_REPORT_ONLY].split("; ")) assert policy_list == ["default-src 'self'"] def test_csp(): + request = RequestFactory().get("/") + 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("; ")) + response = view_without_decorator(request) + mw.process_response(request, response) + assert HEADER_REPORT_ONLY not in response.headers + policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["default-src 'self'"] - @csp(IMG_SRC=["foo.com"], FONT_SRC=["bar.com"]) + @csp({"img-src": ["foo.com"], "font-src": ["bar.com"]}) def view_with_decorator(request): return HttpResponse() - response = view_with_decorator(REQUEST) + response = view_with_decorator(request) assert response._csp_config == {"img-src": ["foo.com"], "font-src": ["bar.com"]} - mw.process_response(REQUEST, response) - policy_list = sorted(response["Content-Security-Policy"].split("; ")) + mw.process_response(request, response) + assert HEADER_REPORT_ONLY not in response.headers + 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) + assert HEADER_REPORT_ONLY not in response.headers + policy_list = sorted(response[HEADER].split("; ")) + assert policy_list == ["default-src 'self'"] + + +def test_csp_ro(): + request = RequestFactory().get("/") + + def view_without_decorator(request): + return HttpResponse() + + response = view_without_decorator(request) + mw.process_response(request, response) + assert HEADER_REPORT_ONLY not in response.headers + policy_list = sorted(response[HEADER].split("; ")) + assert policy_list == ["default-src 'self'"] + + @csp({"img-src": ["foo.com"], "font-src": ["bar.com"]}, REPORT_ONLY=True) + @csp({}) # CSP with no directives effectively removes the header. + def view_with_decorator(request): + return HttpResponse() + + response = view_with_decorator(request) + assert response._csp_config_ro == {"img-src": ["foo.com"], "font-src": ["bar.com"]} + mw.process_response(request, response) + assert HEADER not in response.headers + policy_list = sorted(response[HEADER_REPORT_ONLY].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("; ")) + response = view_without_decorator(request) + mw.process_response(request, response) + assert HEADER_REPORT_ONLY not in response.headers + policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["default-src 'self'"] def test_csp_string_values(): # Test backwards compatibility where values were strings - @csp(IMG_SRC="foo.com", FONT_SRC="bar.com") + request = RequestFactory().get("/") + + @csp({"img-src": "foo.com", "font-src": "bar.com"}) def view_with_decorator(request): return HttpResponse() - response = view_with_decorator(REQUEST) + response = view_with_decorator(request) assert response._csp_config == {"img-src": ["foo.com"], "font-src": ["bar.com"]} - mw.process_response(REQUEST, response) - policy_list = sorted(response["Content-Security-Policy"].split("; ")) + mw.process_response(request, response) + policy_list = sorted(response[HEADER].split("; ")) assert policy_list == ["font-src bar.com", "img-src foo.com"] + + +# Deprecation tests + + +def test_csp_exempt_error(): + with pytest.raises(RuntimeError) as excinfo: + + @csp_exempt + def view(request): + return HttpResponse() + + assert "Incompatible `csp_exempt` decorator usage" in str(excinfo.value) + + +def test_csp_update_error(): + with pytest.raises(RuntimeError) as excinfo: + + @csp_update(IMG_SRC="bar.com") + def view(request): + return HttpResponse() + + assert "Incompatible `csp_update` decorator arguments" in str(excinfo.value) + + +def test_csp_replace_error(): + with pytest.raises(RuntimeError) as excinfo: + + @csp_replace(IMG_SRC="bar.com") + def view(request): + return HttpResponse() + + assert "Incompatible `csp_replace` decorator arguments" in str(excinfo.value) + + +def test_csp_error(): + with pytest.raises(RuntimeError) as excinfo: + + @csp(IMG_SRC=["bar.com"]) + def view(request): + return HttpResponse() + + assert "Incompatible `csp` decorator arguments" in str(excinfo.value) diff --git a/csp/tests/test_middleware.py b/csp/tests/test_middleware.py index 742ad1c..e52971a 100644 --- a/csp/tests/test_middleware.py +++ b/csp/tests/test_middleware.py @@ -6,10 +6,10 @@ from django.test import RequestFactory from django.test.utils import override_settings +from csp.constants import HEADER, HEADER_REPORT_ONLY from csp.middleware import CSPMiddleware from csp.tests.utils import response -HEADER = "Content-Security-Policy" mw = CSPMiddleware(response()) rf = RequestFactory() @@ -21,6 +21,18 @@ def test_add_header(): assert HEADER in response +@override_settings( + CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": ["example.com"]}}, + CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"default-src": ["'self'"]}}, +) +def test_both_headers(): + request = rf.get("/") + response = HttpResponse() + mw.process_response(request, response) + assert HEADER in response + assert HEADER_REPORT_ONLY in response + + def test_exempt(): request = rf.get("/") response = HttpResponse() @@ -29,7 +41,7 @@ def test_exempt(): assert HEADER not in response -@override_settings(CSP_EXCLUDE_URL_PREFIXES=("/inlines-r-us")) +@override_settings(CONTENT_SECURITY_POLICY={"EXCLUDE_URL_PREFIXES": ["/inlines-r-us"]}) def text_exclude(): request = rf.get("/inlines-r-us/foo") response = HttpResponse() @@ -37,7 +49,10 @@ def text_exclude(): assert HEADER not in response -@override_settings(CSP_REPORT_ONLY=True) +@override_settings( + CONTENT_SECURITY_POLICY=None, + CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"default-src": ["'self'"]}}, +) def test_report_only(): request = rf.get("/") response = HttpResponse() @@ -70,7 +85,7 @@ def test_use_update(): assert response[HEADER] == "default-src 'self' example.com" -@override_settings(CSP_IMG_SRC=["foo.com"]) +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["foo.com"]}}) def test_use_replace(): request = rf.get("/") response = HttpResponse() @@ -130,7 +145,7 @@ def test_nonce_regenerated_on_new_request(): assert nonce2 not in response1[HEADER] -@override_settings(CSP_INCLUDE_NONCE_IN=[]) +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"include-nonce-in": []}}) def test_no_nonce_when_disabled_by_settings(): request = rf.get("/") mw.process_request(request) diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py index e4e30b6..188fa3b 100644 --- a/csp/tests/test_utils.py +++ b/csp/tests/test_utils.py @@ -1,19 +1,13 @@ -from django.conf import settings from django.test.utils import override_settings from django.utils.functional import lazy -from csp.utils import build_policy +from csp.utils import build_policy, default_config, DEFAULT_DIRECTIVES -def policy_eq(a, b, msg="%r != %r"): +def policy_eq(a, b): parts_a = sorted(a.split("; ")) parts_b = sorted(b.split("; ")) - assert parts_a == parts_b, msg % (a, b) - - -def test_empty_policy(): - policy = build_policy() - assert "default-src 'self'" == policy + assert parts_a == parts_b, f"{a!r} != {b!r}" def literal(s): @@ -23,121 +17,147 @@ def literal(s): lazy_literal = lazy(literal, str) -@override_settings(CSP_DEFAULT_SRC=["example.com", "example2.com"]) +def test_default_config_none(): + assert default_config(None) is None + + +def test_default_config_empty(): + # Test `default_config` with an empty dict returns defaults. + assert default_config({}) == DEFAULT_DIRECTIVES + + +def test_default_config_drops_unknown(): + # Test `default_config` drops unknown keys. + config = {"foo-src": ["example.com"]} + assert default_config(config) == DEFAULT_DIRECTIVES + + +def test_default_config(): + # Test `default_config` keeps config along with defaults. + config = {"img-src": ["example.com"]} + assert default_config(config) == {**DEFAULT_DIRECTIVES, **config} + + +def test_empty_policy(): + policy = build_policy() + policy_eq("default-src 'self'", policy) + + +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": ["example.com", "example2.com"]}}) def test_default_src(): policy = build_policy() - assert "default-src example.com example2.com" == policy + policy_eq("default-src example.com example2.com", policy) -@override_settings(CSP_SCRIPT_SRC=["example.com"]) +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"sandbox": ["allow-scripts"]}}) def test_sandbox(): policy = build_policy() policy_eq("default-src 'self'; sandbox allow-scripts", policy) -@override_settings(CSP_SANDBOX=[]) +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"sandbox": []}}) def test_sandbox_empty(): policy = build_policy() policy_eq("default-src 'self'; sandbox", policy) -@override_settings(CSP_REPORT_URI="/foo") +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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", policy) @@ -149,7 +169,7 @@ def test_update_missing_setting(): policy_eq("default-src 'self'; img-src example.com", policy) -@override_settings(CSP_IMG_SRC=["example.com"]) +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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) @@ -166,7 +186,7 @@ def test_config(): policy_eq("default-src 'none'; img-src 'self'", policy) -@override_settings(CSP_IMG_SRC=("example.com",)) +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ("example.com",)}}) def test_update_string(): """ GitHub issue #40 - given project settings as a tuple, and @@ -176,7 +196,7 @@ def test_update_string(): policy_eq("default-src 'self'; img-src example.com example2.com", policy) -@override_settings(CSP_IMG_SRC=("example.com",)) +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ("example.com",)}}) def test_replace_string(): """ Demonstrate that GitHub issue #40 doesn't affect replacements @@ -185,67 +205,67 @@ def test_replace_string(): policy_eq("default-src 'self'; img-src example2.com", policy) -@override_settings(CSP_FORM_ACTION=["example.com"]) +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"trusted-types": ["strictPolicy", "laxPolicy", "default", "'allow-duplicates'"]}}) def test_trusted_types(): policy = build_policy() policy_eq( @@ -254,13 +274,13 @@ def test_trusted_types(): ) -@override_settings(CSP_UPGRADE_INSECURE_REQUESTS=True) +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"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_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"block-all-mixed-content": True}}) def test_block_all_mixed_content(): policy = build_policy() policy_eq("default-src 'self'; block-all-mixed-content", policy) @@ -271,7 +291,7 @@ def test_nonce(): policy_eq("default-src 'self' 'nonce-abc123'", policy) -@override_settings(CSP_INCLUDE_NONCE_IN=["script-src", "style-src"]) +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"include-nonce-in": ["script-src", "style-src"]}}) def test_nonce_include_in(): policy = build_policy(nonce="abc123") policy_eq( @@ -280,8 +300,16 @@ def test_nonce_include_in(): ) -@override_settings() 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_boolean_directives(): + for directive in ["upgrade-insecure-requests", "block-all-mixed-content"]: + with override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {directive: True}}): + policy = build_policy() + policy_eq(f"default-src 'self'; {directive}", policy) + with override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {directive: False}}): + policy = build_policy() + policy_eq("default-src 'self'", policy) diff --git a/csp/utils.py b/csp/utils.py index 821912d..cf62ee1 100644 --- a/csp/utils.py +++ b/csp/utils.py @@ -7,56 +7,78 @@ from django.utils.encoding import force_str -def from_settings(): - return { - # Fetch Directives - "child-src": getattr(settings, "CSP_CHILD_SRC", None), - "connect-src": getattr(settings, "CSP_CONNECT_SRC", None), - "default-src": getattr(settings, "CSP_DEFAULT_SRC", ["'self'"]), - "script-src": getattr(settings, "CSP_SCRIPT_SRC", None), - "script-src-attr": getattr(settings, "CSP_SCRIPT_SRC_ATTR", None), - "script-src-elem": getattr(settings, "CSP_SCRIPT_SRC_ELEM", None), - "object-src": getattr(settings, "CSP_OBJECT_SRC", None), - "style-src": getattr(settings, "CSP_STYLE_SRC", None), - "style-src-attr": getattr(settings, "CSP_STYLE_SRC_ATTR", None), - "style-src-elem": getattr(settings, "CSP_STYLE_SRC_ELEM", None), - "font-src": getattr(settings, "CSP_FONT_SRC", None), - "frame-src": getattr(settings, "CSP_FRAME_SRC", None), - "img-src": getattr(settings, "CSP_IMG_SRC", None), - "manifest-src": getattr(settings, "CSP_MANIFEST_SRC", None), - "media-src": getattr(settings, "CSP_MEDIA_SRC", None), - "prefetch-src": getattr(settings, "CSP_PREFETCH_SRC", None), - "worker-src": getattr(settings, "CSP_WORKER_SRC", None), - # Document Directives - "base-uri": getattr(settings, "CSP_BASE_URI", None), - "plugin-types": getattr(settings, "CSP_PLUGIN_TYPES", None), - "sandbox": getattr(settings, "CSP_SANDBOX", None), - # Navigation Directives - "form-action": getattr(settings, "CSP_FORM_ACTION", None), - "frame-ancestors": getattr(settings, "CSP_FRAME_ANCESTORS", None), - "navigate-to": getattr(settings, "CSP_NAVIGATE_TO", None), - # Reporting Directives - "report-uri": getattr(settings, "CSP_REPORT_URI", None), - "report-to": getattr(settings, "CSP_REPORT_TO", None), - "require-sri-for": getattr(settings, "CSP_REQUIRE_SRI_FOR", None), - # trusted Types Directives - "require-trusted-types-for": getattr(settings, "CSP_REQUIRE_TRUSTED_TYPES_FOR", None), - "trusted-types": getattr(settings, "CSP_TRUSTED_TYPES", None), - # Other Directives - "upgrade-insecure-requests": getattr(settings, "CSP_UPGRADE_INSECURE_REQUESTS", False), - "block-all-mixed-content": getattr(settings, "CSP_BLOCK_ALL_MIXED_CONTENT", False), - } - - -def build_policy(config=None, update=None, replace=None, nonce=None): +DEFAULT_DIRECTIVES = { + # 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, # Deprecated. + # Document Directives + "base-uri": None, + "plugin-types": None, # Deprecated. + "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 + "webrtc": None, + "worker-src": None, + # Directives Defined in Other Documents + "upgrade-insecure-requests": None, + "block-all-mixed-content": None, # Deprecated. + # Pseudo-directive that affects other directives. + "include-nonce-in": None, +} + + +def default_config(csp): + if csp is None: + return None + # Make a copy of the passed in config to avoid mutating it, and also to drop any unknown keys. + config = {} + for key, value in DEFAULT_DIRECTIVES.items(): + config[key] = csp.get(key, value) + return config + + +def build_policy(config=None, update=None, replace=None, nonce=None, report_only=False): """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 + if report_only: + config = getattr(settings, "CONTENT_SECURITY_POLICY_REPORT_ONLY", {}) + config = default_config(config.get("DIRECTIVES", {})) if config else None + else: + config = getattr(settings, "CONTENT_SECURITY_POLICY", {}) + config = default_config(config.get("DIRECTIVES", {})) if config else None + + # If config is still `None`, return empty policy. + if config is None: + return "" update = update if update is not None else {} replace = replace if replace is not None else {} + csp = {} for k in set(chain(config, replace)): @@ -80,8 +102,10 @@ def build_policy(config=None, update=None, replace=None, nonce=None): csp[k] += tuple(v) report_uri = csp.pop("report-uri", None) + include_nonce_in = csp.pop("include-nonce-in", []) policy_parts = {} + for key, value in csp.items(): # flag directives with an empty directive value if len(value) and value[0] is True: @@ -96,10 +120,9 @@ 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] = ("{} {}".format(policy, "'nonce-%s'" % nonce)).strip() + policy_parts[section] = f"{policy} 'nonce-{nonce}'".strip() return "; ".join([f"{k} {val}".strip() for k, val in policy_parts.items()]) diff --git a/docs/configuration.rst b/docs/configuration.rst index 7126391..834b417 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -15,149 +15,234 @@ before configuring django-csp. policies and even errors when mistakenly configuring them as a ``string``. +Migrating from django-csp <= 3.8 +================================ + +Version 4.0 of django-csp introduces a new configuration format that breaks compatibility with +previous versions. If you are migrating from django-csp 3.8 or lower, you will need to update your +settings to the new format. See the :ref:`migration guide ` for more +information. + +Configuration +============= + +All configuration of django-csp is done in your Django settings file with the +``CONTENT_SECURITY_POLICY`` setting or the ``CONTENT_SECURITY_POLICY_REPORT_ONLY`` setting. Each of these +settings expects a dictionary representing a policy. + +The ``CONTENT_SECURITY_POLICY`` setting is your enforcable policy. + +The ``CONTENT_SECURITY_POLICY_REPORT_ONLY`` setting is your report-only policy. This policy is +used to test the policy without breaking the site. It is useful when setting this policy to be +slightly more strict than the default policy to see what would be blocked if the policy was enforced. + +The following is an example of a policy configuration with a default policy and a report-only +policy. The default policy is considered a "relaxed" policy that allows for the most flexibility +while still providing a good level of security. The report-only policy is considered a step towards +a more slightly strict policy and is used to test the policy without breaking the site. + +.. code-block:: python + + CONTENT_SECURITY_POLICY = { + "EXCLUDE_URL_PREFIXES": ["/excluded-path/"], + "DIRECTIVES": { + "default-src": ["'self'", "cdn.example.net"], + "frame-ancestors": ["'self'"], + "form-action": ["'self'"], + "report-uri": "/csp-report/", + }, + } + + CONTENT_SECURITY_POLICY_REPORT_ONLY = { + "EXCLUDE_URL_PREFIXES": ["/excluded-path/"], + "DIRECTIVES": { + "default-src": ["'none'"], + "connect-src": ["'self'"], + "img-src": ["'self'"], + "form-action": ["'self'"], + "frame-ancestors": ["'self'"], + "script-src": ["'self'"], + "style-src": ["'self'"], + "upgrade-insecure-requests": True, + "report-uri": "/csp-report/", + }, + } + + Policy Settings =============== -These settings affect the policy in the header. The defaults are in *italics*. +At the top level of the policy dictionary, these are the keys that can be used to configure the +policy. -.. note:: - Deprecated features of CSP in general have been moved to the bottom of this list. +``EXCLUDE_URL_PREFIXES`` + A ``tuple`` of URL prefixes. URLs beginning with any of these will not get the CSP headers. + *()* -.. warning:: - The "special" source values of ``'self'``, ``'unsafe-inline'``, - ``'unsafe-eval'``, ``'none'`` and hash-source (``'sha256-...'``) must be - quoted! e.g.: ``CSP_DEFAULT_SRC = ("'self'",)``. Without quotes they will - not work as intended. + .. warning:: -``CSP_DEFAULT_SRC`` - Set the ``default-src`` directive. A ``tuple`` or ``list`` of values, - e.g.: ``("'self'", 'cdn.example.net')``. *["'self'"]* + Excluding any path on your site will eliminate the benefits of CSP everywhere on your site. + The typical browser security model for JavaScript considers all paths alike. A Cross-Site + Scripting flaw on, e.g., ``excluded-page/`` can therefore be leveraged to access everything + on the same origin. -``CSP_SCRIPT_SRC`` - Set the ``script-src`` directive. A ``tuple`` or ``list``. *None* + # TODO: I can't find any documentation on the above warning. -``CSP_SCRIPT_SRC_ATTR`` - Set the ``script-src-attr`` directive. A ``tuple`` or ``list``. *None* +``REPORT_PERCENTAGE`` + Percentage of requests that should see the ``report-uri`` directive. + Use this to throttle the number of CSP violation reports made to your + ``report-uri``. An **integer** between 0 and 100 (0 = no reports at all). + Ignored if ``report-uri`` isn't set. -``CSP_SCRIPT_SRC_ELEM`` - Set the ``script-src-elem`` directive. A ``tuple`` or ``list``. *None* +``DIRECTIVES`` + A dictionary of policy directives. Each key in the dictionary is a directive and the value is a + list of sources for that directive. The following is a list of all the directives that can be + configured. -``CSP_IMG_SRC`` - Set the ``img-src`` directive. A ``tuple`` or ``list``. *None* + .. note:: + The "special" source values of ``'self'``, ``'unsafe-inline'``, ``'unsafe-eval'``, + ``'none'`` and hash-source (``'sha256-...'``) must be quoted! + e.g.: ``"default-src": ["'self'"]``. Without quotes they will not work as intended. -``CSP_OBJECT_SRC`` - Set the ``object-src`` directive. A ``tuple`` or ``list``. *None* + .. note:: + Deprecated features of CSP in general have been moved to the bottom of this list. -``CSP_MEDIA_SRC`` - Set the ``media-src`` directive. A ``tuple`` or ``list``. *None* + .. warning:: + The ``'unsafe-inline'`` and ``'unsafe-eval'`` sources are considered harmful and should be + avoided. They are included here for completeness, but should not be used in production. -``CSP_FRAME_SRC`` - Set the ``frame-src`` directive. A ``tuple`` or ``list``. *None* + ``default-src`` + Set the ``default-src`` directive. A ``tuple`` or ``list`` of values, + e.g.: ``("'self'", 'cdn.example.net')``. *["'self'"]* -``CSP_FONT_SRC`` - Set the ``font-src`` directive. A ``tuple`` or ``list``. *None* + ``script-src`` + Set the ``script-src`` directive. A ``tuple`` or ``list``. *None* -``CSP_CONNECT_SRC`` - Set the ``connect-src`` directive. A ``tuple`` or ``list``. *None* + ``script-src-attr`` + Set the ``script-src-attr`` directive. A ``tuple`` or ``list``. *None* -``CSP_STYLE_SRC`` - Set the ``style-src`` directive. A ``tuple`` or ``list``. *None* + ``script-src-elem`` + Set the ``script-src-elem`` directive. A ``tuple`` or ``list``. *None* -``CSP_STYLE_SRC_ATTR`` - Set the ``style-src-attr`` directive. A ``tuple`` or ``list``. *None* + ``img-src`` + Set the ``img-src`` directive. A ``tuple`` or ``list``. *None* -``CSP_STYLE_SRC_ELEM`` - Set the ``style-src-elem`` directive. A ``tuple`` or ``list``. *None* + ``object-src`` + Set the ``object-src`` directive. A ``tuple`` or ``list``. *None* -``CSP_BASE_URI`` - Set the ``base-uri`` directive. A ``tuple`` or ``list``. *None* + ``media-src`` + Set the ``media-src`` directive. A ``tuple`` or ``list``. *None* - Note: This doesn't use ``default-src`` as a fall-back. + ``frame-src`` + Set the ``frame-src`` directive. A ``tuple`` or ``list``. *None* -``CSP_CHILD_SRC`` - Set the ``child-src`` directive. A ``tuple`` or ``list``. *None* + ``font-src`` + Set the ``font-src`` directive. A ``tuple`` or ``list``. *None* -``CSP_FRAME_ANCESTORS`` - Set the ``frame-ancestors`` directive. A ``tuple`` or ``list``. *None* + ``connect-src`` + Set the ``connect-src`` directive. A ``tuple`` or ``list``. *None* - Note: This doesn't use ``default-src`` as a fall-back. + ``style-src`` + Set the ``style-src`` directive. A ``tuple`` or ``list``. *None* -``CSP_NAVIGATE_TO`` - Set the ``navigate-to`` directive. A ``tuple`` or ``list``. *None* + ``style-src-attr`` + Set the ``style-src-attr`` directive. A ``tuple`` or ``list``. *None* - Note: This doesn't use ``default-src`` as a fall-back. + ``style-src-elem`` + Set the ``style-src-elem`` directive. A ``tuple`` or ``list``. *None* -``CSP_FORM_ACTION`` - Set the ``FORM_ACTION`` directive. A ``tuple`` or ``list``. *None* + ``base-uri`` + Set the ``base-uri`` directive. A ``tuple`` or ``list``. *None* - Note: This doesn't use ``default-src`` as a fall-back. + Note: This doesn't use ``default-src`` as a fall-back. -``CSP_SANDBOX`` - Set the ``sandbox`` directive. A ``tuple`` or ``list``. *None* + ``child-src`` + Set the ``child-src`` directive. A ``tuple`` or ``list``. *None* - Note: This doesn't use ``default-src`` as a fall-back. + ``frame-ancestors`` + Set the ``frame-ancestors`` directive. A ``tuple`` or ``list``. *None* -``CSP_REPORT_URI`` - Set the ``report-uri`` directive. A ``tuple`` or ``list`` of URIs. - Each URI can be a full or relative URI. *None* + Note: This doesn't use ``default-src`` as a fall-back. - Note: This doesn't use ``default-src`` as a fall-back. + ``navigate-to`` + Set the ``navigate-to`` directive. A ``tuple`` or ``list``. *None* + + Note: This doesn't use ``default-src`` as a fall-back. + + ``form-action`` + Set the ``FORM_ACTION`` directive. A ``tuple`` or ``list``. *None* + + Note: This doesn't use ``default-src`` as a fall-back. + + ``sandbox`` + Set the ``sandbox`` directive. A ``tuple`` or ``list``. *None* -``CSP_REPORT_TO`` - Set the ``report-to`` directive. A ``string`` describing a reporting - group. *None* + Note: This doesn't use ``default-src`` as a fall-back. - See Section 1.2: https://w3c.github.io/reporting/#group + ``report-uri`` + Set the ``report-uri`` directive. A ``tuple`` or ``list`` of URIs. + Each URI can be a full or relative URI. *None* - Also `see this MDN note on `_ ``report-uri`` and ``report-to``. + Note: This doesn't use ``default-src`` as a fall-back. -``CSP_MANIFEST_SRC`` - Set the ``manifest-src`` directive. A ``tuple`` or ``list``. *None* + ``report-to`` + Set the ``report-to`` directive. A ``string`` describing a reporting + group. *None* -``CSP_WORKER_SRC`` - Set the ``worker-src`` directive. A ``tuple`` or ``list``. *None* + See Section 1.2: https://w3c.github.io/reporting/#group -``CSP_REQUIRE_SRI_FOR`` - Set the ``require-sri-for`` directive. A ``tuple`` or ``list``. *None* + Also `see this MDN note on `_ ``report-uri`` and ``report-to``. - Valid values: a ``list`` containing ``'script'``, ``'style'``, or both. + ``manifest-src`` + Set the ``manifest-src`` directive. A ``tuple`` or ``list``. *None* - Spec: require-sri-for-known-tokens_ + ``worker-src`` + Set the ``worker-src`` directive. A ``tuple`` or ``list``. *None* -``CSP_UPGRADE_INSECURE_REQUESTS`` - Include ``upgrade-insecure-requests`` directive. A ``boolean``. *False* + ``require-sri-for`` + Set the ``require-sri-for`` directive. A ``tuple`` or ``list``. *None* - Spec: upgrade-insecure-requests_ + Valid values: a ``list`` containing ``'script'``, ``'style'``, or both. -``CSP_REQUIRE_TRUSTED_TYPES_FOR`` - Include ``require-trusted-types-for`` directive. - A ``tuple`` or ``list``. *None* + Spec: require-sri-for-known-tokens_ - Valid values: ``["'script'"]`` + ``upgrade-insecure-requests`` + Include ``upgrade-insecure-requests`` directive. A ``boolean``. *False* -``CSP_TRUSTED_TYPES`` - Include ``trusted-types`` directive. - A ``tuple`` or ``list``. *None* + Spec: upgrade-insecure-requests_ - Valid values: a ``list`` of allowed policy names that may include - ``default`` and/or ``'allow-duplicates'`` + ``require-trusted-types-for`` + Include ``require-trusted-types-for`` directive. + A ``tuple`` or ``list``. *None* -``CSP_INCLUDE_NONCE_IN`` - Include dynamically generated nonce in all listed directives. - A ``tuple`` or ``list``, e.g.: ``CSP_INCLUDE_NONCE_IN = ['script-src']`` - will add ``'nonce-'`` to the ``script-src`` directive. - *['default-src']* + Valid values: ``["'script'"]`` + + ``trusted-types`` + Include ``trusted-types`` directive. + A ``tuple`` or ``list``. *None* + + Valid values: a ``list`` of allowed policy names that may include + ``default`` and/or ``'allow-duplicates'`` + + ``include-nonce-in`` + A ``tuple`` of directives to include a nonce in. *['default-src']* Any directive that is + included in this list will have a nonce value added to it of the form ``'nonce-{nonce-value}'``. + + Note: This is a bit of a "pseudo"-directive. It's not a real CSP directive as defined by the + spec, but it's used to determine which directives should include a nonce value. This is + useful for adding nonces to scripts and styles. + + Note: The nonce value will only be generated if ``request.csp_nonce`` is accessed during the + request/response cycle. - Note: The nonce value will only be generated if ``request.csp_nonce`` - is accessed during the request/response cycle. Deprecated CSP settings ----------------------- -The following settings are still configurable, but are considered deprecated +The following ``DIRECTIVES`` settings are still configurable, but are considered deprecated in terms of the latest implementation of the relevant spec. -``CSP_BLOCK_ALL_MIXED_CONTENT`` +``block-all-mixed-content`` Include ``block-all-mixed-content`` directive. A ``boolean``. *False* Related `note on MDN `_. @@ -165,8 +250,7 @@ in terms of the latest implementation of the relevant spec. Spec: block-all-mixed-content_ - -``CSP_PLUGIN_TYPES`` +``plugin-types`` Set the ``plugin-types`` directive. A ``tuple`` or ``list``. *None* Note: This doesn't use ``default-src`` as a fall-back. @@ -174,7 +258,7 @@ in terms of the latest implementation of the relevant spec. Related `note on MDN `_. -``CSP_PREFETCH_SRC`` +``prefetch-src`` Set the ``prefetch-src`` directive. A ``tuple`` or ``list``. *None* Related `note on MDN `_. @@ -187,31 +271,6 @@ 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* - - See the spec_ and the chapter on :ref:`reports ` for - more info. - -``CSP_EXCLUDE_URL_PREFIXES`` - A ``tuple`` (*not* a ``list``) of URL prefixes. URLs beginning with any - of these will not get the CSP headers. *()* - -.. warning:: - - Excluding any path on your site will eliminate the benefits of CSP - everywhere on your site. The typical browser security model for - JavaScript considers all paths alike. A Cross-Site Scripting flaw - on, e.g., ``excluded-page/`` can therefore be leveraged to access - everything on the same origin. - .. _Content-Security-Policy: https://www.w3.org/TR/CSP/ .. _Content-Security-Policy-L3: https://w3c.github.io/webappsec-csp/ .. _spec: Content-Security-Policy_ @@ -221,3 +280,4 @@ These settings control the behavior of django-csp. Defaults are in .. _block-all-mixed-content_mdn: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/block-all-mixed-content .. _plugin_types_mdn: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/plugin-types .. _prefetch_src_mdn: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/prefetch-src +.. _strict-csp: https://csp.withgoogle.com/docs/strict-csp.html \ No newline at end of file diff --git a/docs/contributing.rst b/docs/contributing.rst index 4fc170e..3c98b24 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -34,6 +34,10 @@ Then just `pytest`_ to run the tests:: pytest +To run the tests with coverage and get a report, use the following command:: + + pytest --cov=csp --cov-config=.coveragerc + .. _PEP8: http://www.python.org/dev/peps/pep-0008/ .. _ruff: https://pypi.org/project/ruff/ diff --git a/docs/decorators.rst b/docs/decorators.rst index 3ba6183..6c36378 100644 --- a/docs/decorators.rst +++ b/docs/decorators.rst @@ -4,9 +4,8 @@ Modifying the Policy with Decorators ==================================== -Content Security Policies should be restricted and paranoid by default. -You may, on some views, need to expand or change the policy. django-csp -includes four decorators to help. +Content Security Policies should be restricted and paranoid by default. You may, on some views, +need to expand or change the policy. django-csp includes four decorators to help. ``@csp_exempt`` @@ -20,12 +19,17 @@ view. from csp.decorators import csp_exempt # Will not have a CSP header. - @csp_exempt + @csp_exempt() def myview(request): return render(...) -You can manually set this on a per-response basis by setting the -``_csp_exempt`` attribute on the response to ``True``:: + # Will not have a CSP report-only header. + @csp_exempt(REPORT_ONLY=True) + def myview(request): + return render(...) + +You can manually set this on a per-response basis by setting the ``_csp_exempt`` +or ``_csp_exempt_ro`` attribute on the response to ``True``:: # Also will not have a CSP header. def myview(request): @@ -37,28 +41,31 @@ You can manually set this on a per-response basis by setting the ``@csp_update`` =============== -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_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. .. 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':: + 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 -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. +The arguments to the decorator are the same as the :ref:`settings `. The +decorator excpects a single dictionary argument, where the keys are the directives and the values +are either strings, lists or tuples. An optional argument, ``REPORT_ONLY``, can be set to ``True`` +to update the report-only policy instead of the enforced policy. :: from csp.decorators import csp_update - # Will allow images from imgsrv.com. - @csp_update(IMG_SRC='imgsrv.com') + # Will append imgsrv.com to the list of values for `img-src` in the enforced policy. + @csp_update({"img-src": "imgsrv.com"}) + def myview(request): + return render(...) + + # Will append cdn-img.com to the list of values for `img-src` in the report-only policy. + @csp_update({"img-src": "cdn-img.com"}, REPORT_ONLY=True) def myview(request): return render(...) @@ -66,18 +73,21 @@ or tuples. ``@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``.) -If the specified value is None, the corresponding key will not be included. +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``.) If the specified value is None, the corresponding key will not be included. The arguments and values are the same as ``@csp_update``:: from csp.decorators import csp_replace - # settings.CSP_IMG_SRC = ['imgsrv.com'] - # Will allow images from imgsrv2.com, but not imgsrv.com. - @csp_replace(IMG_SRC='imgsrv2.com') + # Will allow images only from imgsrv2.com in the enforced policy. + @csp_replace({"img-src": "imgsrv2.com"}) + def myview(request): + return render(...) + + # Will allow images only from cdn-img2.com in the report-only policy. + @csp_replace({"img-src": "imgsrv2.com"}) def myview(request): return render(...) @@ -85,13 +95,23 @@ The arguments and values are the same as ``@csp_update``:: ``@csp`` ======== -If you need to set the entire policy on a view, ignoring all the -settings, you can use the ``@csp`` decorator. The arguments and values -are as above:: +If you need to set the entire policy on a view, ignoring all the settings, you can use the ``@csp`` +decorator. This, and the other decorators, can be stacked to update both policies if both are in +use, as shown below. The arguments and values are as above:: from csp.decorators import csp - @csp(DEFAULT_SRC=["'self'"], IMG_SRC=['imgsrv.com'], - SCRIPT_SRC=['scriptsrv.com', 'googleanalytics.com']) + @csp({ + "default_src": ["'self'"], + "img-src": ["imgsrv.com"], + "script-src": ["scriptsrv.com", "googleanalytics.com", "'unsafe-inline'"]} + }) + @csp({ + "default_src": ["'self'"], + "img-src": ["imgsrv.com"], + "script-src": ["scriptsrv.com", "googleanalytics.com"]}, + "frame-src": ["'self'"], + REPORT_ONLY=True + }) def myview(request): return render(...) diff --git a/docs/index.rst b/docs/index.rst index 5597cfb..419ab76 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,6 +21,7 @@ Contents: installation configuration + migration-guide decorators nonce trusted_types diff --git a/docs/installation.rst b/docs/installation.rst index 7745419..f593de9 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -4,26 +4,33 @@ Installing django-csp ===================== -First, install django-csp via pip or from source:: +First, install django-csp via pip or from source: - # pip - $ pip install django-csp +.. code-block:: bash -:: + pip install django-csp - # source - $ git clone https://github.com/mozilla/django-csp.git - $ cd django-csp - $ python setup.py install +Add the csp app to your ``INSTALLED_APPS`` in your project's ``settings`` module: + +.. code-block:: python + + INSTALLED_APPS = ( + # ... + "csp", + # ... + ) Now edit your project's ``settings`` module, to add the django-csp middleware -to ``MIDDLEWARE``, like so:: +to ``MIDDLEWARE``, like so: + +.. code-block:: python MIDDLEWARE = ( # ... - 'csp.middleware.CSPMiddleware', + "csp.middleware.CSPMiddleware", # ... ) + Note: Middleware order does not matter unless you have other middleware modifying the CSP header. That should do it! Go on to :ref:`configuring CSP `. diff --git a/docs/migration-guide.rst b/docs/migration-guide.rst new file mode 100644 index 0000000..a0772e6 --- /dev/null +++ b/docs/migration-guide.rst @@ -0,0 +1,172 @@ +.. _migration-guide-chapter: + +============================== +django-csp 4.0 Migration Guide +============================== + +Overview +======== + +In the latest version of `django-csp`, the format for configuring Content Security Policy (CSP) +settings has been updated are are backwards-incompatible with prior versions. The previous approach +of using individual settings prefixed with ``CSP_`` for each directive is no longer supported. +Instead, all CSP settings are now consolidated into one of two dict-based settings: +``CONTENT_SECURITY_POLICY`` or ``CONTENT_SECURITY_POLICY_REPORT_ONLY``. + +Migrating from the Old Settings Format +====================================== + +Update `django-csp` +------------------- + +First, update the `django-csp` package to the latest version that supports the new settings format. +You can do this by running: + +.. code-block:: bash + + pip install -U django-csp + +Add the `csp` app to `INSTALLED_APPS` +------------------------------------- + +In your Django project's `settings.py` file, add the `csp` app to the ``INSTALLED_APPS`` setting: + +.. code-block:: python + + INSTALLED_APPS = [ + # ... + "csp", + # ... + ] + +Run the Django check command +---------------------------- + +This is optional but can help kick start the new settings configuration for you. Run the Django +check command which will look for old settings and output a configuration in the new format: + +.. code-block:: bash + + python manage.py check + +This can help you identify the existing CSP settings in your project and provide a starting point +for migrating to the new format. + +Identify Existing CSP Settings +------------------------------ + +Locate all the existing CSP settings in your Django project. These settings start with the +``CSP_`` prefix, such as ``CSP_DEFAULT_SRC``, ``CSP_SCRIPT_SRC``, ``CSP_IMG_SRC``, etc. + +Create the New Settings Dictionary +---------------------------------- + +In your Django project's `settings.py` file, create a new dictionary called +``CONTENT_SECURITY_POLICY`` or ``CONTENT_SECURITY_POLICY_REPORT_ONLY``, depending on whether you want to +enforce the policy or only report violations, or both. Use the output from the Django check command +as a starting point to populate this dictionary. + +Migrate Existing Settings +------------------------- + +Migrate your existing CSP settings to the new format by populating the ``DIRECTIVES`` dictionary +inside the ``CONTENT_SECURITY_POLICY`` setting. The keys of the ``DIRECTIVES`` dictionary should be the +CSP directive names in lowercase, and the values should be lists containing the corresponding +sources. The Django check command output can help you identify the directive names and sources. + +For example, if you had the following old settings: + +.. code-block:: python + + CSP_DEFAULT_SRC = ["'self'", "*.example.com"] + CSP_SCRIPT_SRC = ["'self'", "js.cdn.com/example/"] + CSP_IMG_SRC = ["'self'", "data:", "example.com"] + CSP_EXCLUDE_URL_PREFIXES = ["/admin"] + +The new settings would be: + +.. code-block:: python + + CONTENT_SECURITY_POLICY = { + "EXCLUDE_URL_PREFIXES": ["/admin"], + "DIRECTIVES": { + "default-src": ["'self'", "*.example.com"], + "script-src": ["'self'", "js.cdn.com/example/"], + "img-src": ["'self'", "data:", "example.com"], + }, + } + +.. note:: + + The keys in the ``DIRECTIVES`` dictionary, the directive names, are in lowercase and use dashes + instead of underscores to match the CSP specification. + +.. note:: + + If you were using the ``CSP_REPORT_PERCENTAGE`` setting, this should be updated to be an integer + percentage and not a decimal value in the new settings format. For example, if you had + ``CSP_REPORT_PERCENTAGE = 0.1``, this should be updated to: + + .. code-block:: python + + CONTENT_SECURITY_POLICY = { + "REPORT_PERCENTAGE": 10, + "DIRECTIVES": { + "report-uri": "/csp-report/", + # ... + }, + } + +Remove Old Settings +------------------- + +After migrating to the new settings format, remove all the old ``CSP_`` prefixed settings from your +`settings.py` file. + +Update the CSP decorators +------------------------- + +If you are using the CSP decorators in your views, those will need to be updated as well. The +decorators now accept a dictionary containing the CSP directives as an argument. For example: + +.. code-block:: python + + from csp.decorators import csp_update + + + @csp_update({"default-src": ["another-url.com"]}) + def my_view(request): ... + +Additionally, each decorator now takes an optional ``report_only`` argument to specify whether the +policy should be enforced or only report violations. For example: + +.. code-block:: python + + from csp.decorators import csp + + + @csp({"default-src": ["'self'"]}, report_only=True) + def my_view(request): ... + +Due to the addition of the ``report_only`` argument and for consistency, the ``csp_exempt`` +decorator now requires parentheses when used with and without arguments. For example: + +.. code-block:: python + + from csp.decorators import csp_exempt + + + @csp_exempt() + @csp_exempt(report_only=True) + def my_view(request): ... + +Look for uses of the following decorators in your code: ``@csp``, ``@csp_update``, ``@csp_replace``, +and ``@csp_exempt``. + +Conclusion +========== + +By following this migration guide, you should be able to successfully update your Django project to +use the new dict-based CSP settings format introduced in the latest version of `django-csp`. This +change aligns the package with the latest CSP specification and provides a more organized and +flexible way to configure your Content Security Policy. diff --git a/docs/nonce.rst b/docs/nonce.rst index 7bfb5f6..12f1351 100644 --- a/docs/nonce.rst +++ b/docs/nonce.rst @@ -1,9 +1,10 @@ ============================== Using the generated CSP nonce ============================== -When ``CSP_INCLUDE_NONCE_IN`` is configured, the nonce value is returned in the CSP headers **if it is used**, e.g. by evaluating the nonce in your template. -To actually make the browser do anything with this value, you will need to include it in the attributes of -the tags that you wish to mark as safe. +When ``include-nonce-in`` is configured, the nonce value is returned in the CSP headers **if it is +used**, e.g. by evaluating the nonce in your template. To actually make the browser do anything +with this value, you will need to include it in the attributes of the tags that you wish to mark as +safe. .. Note:: @@ -16,17 +17,19 @@ the tags that you wish to mark as safe. ``Middleware`` ============== -Installing the middleware creates a lazily evaluated property ``csp_nonce`` and attaches it to all incoming requests. +Installing the middleware creates a lazily evaluated property ``csp_nonce`` and attaches it to all +incoming requests. .. code-block:: python MIDDLEWARE = ( - #... - 'csp.middleware.CSPMiddleware', - #... + # ... + "csp.middleware.CSPMiddleware", + # ... ) -This value can be accessed directly on the request object in any view or template and manually appended to any script element like so - +This value can be accessed directly on the request object in any view or template and manually +appended to any script element like so - .. code-block:: html @@ -34,7 +37,8 @@ This value can be accessed directly on the request object in any view or templat var hello="world"; -Assuming the ``CSP_INCLUDE_NONCE_IN`` list contains the ``script-src`` directive, this will result in the above script being allowed. +Assuming the ``include-nonce-in`` list contains the ``script-src`` directive, this will result in +the above script being allowed. .. Note:: @@ -43,7 +47,10 @@ Assuming the ``CSP_INCLUDE_NONCE_IN`` list contains the ``script-src`` directive ``Context Processor`` ===================== -This library contains an optional context processor, adding ``csp.context_processors.nonce`` to your configured context processors exposes a variable called ``CSP_NONCE`` into the global template context. This is simple shorthand for ``request.csp_nonce``, but can be useful if you have many occurrences of script tags. +This library contains an optional context processor, adding ``csp.context_processors.nonce`` to your +configured context processors exposes a variable called ``CSP_NONCE`` into the global template +context. This is simple shorthand for ``request.csp_nonce``, but can be useful if you have many +occurrences of script tags. .. code-block:: jinja @@ -57,12 +64,18 @@ This library contains an optional context processor, adding ``csp.context_proces .. note:: - If you're making use of ``csp.extensions.NoncedScript`` you need to have ``jinja2>=2.9.6`` installed, so please make sure to either use ``django-csp[jinja2]`` in your requirements or define it yourself. + If you're making use of ``csp.extensions.NoncedScript`` you need to have ``jinja2>=2.9.6`` + installed, so please make sure to either use ``django-csp[jinja2]`` in your requirements or + define it yourself. -It can be easy to forget to include the ``nonce`` property in a script tag, so there is also a ``script`` template tag available for both Django templates and Jinja environments. +It can be easy to forget to include the ``nonce`` property in a script tag, so there is also a +``script`` template tag available for both Django templates and Jinja environments. -This tag will output a properly nonced script every time. For the sake of syntax highlighting, you can wrap the content inside of the ``script`` tag in ``