From 9b2cee0866e47f5beb8f21f7375c2b721333efab Mon Sep 17 00:00:00 2001 From: Rob Hudson Date: Fri, 7 Jun 2024 11:26:03 -0700 Subject: [PATCH] Move to NONCE sentinel instead of 'include-nonce-in' --- CHANGES | 3 +++ csp/checks.py | 7 +++++- csp/constants.py | 15 ++++++++++++ csp/tests/settings.py | 5 +++- csp/tests/test_checks.py | 4 ++-- csp/tests/test_constants.py | 7 ++++++ csp/tests/test_decorators.py | 10 ++++---- csp/tests/test_middleware.py | 10 -------- csp/tests/test_utils.py | 19 ++++++++++----- csp/utils.py | 30 +++++++++++------------ docs/configuration.rst | 46 +++++++++++++++++++++++------------- docs/migration-guide.rst | 36 ++++++++++++++++++++++++---- docs/nonce.rst | 6 ++--- 13 files changed, 134 insertions(+), 64 deletions(-) create mode 100644 csp/tests/test_constants.py diff --git a/CHANGES b/CHANGES index 01eeb62..ec36bfc 100644 --- a/CHANGES +++ b/CHANGES @@ -8,11 +8,14 @@ CHANGES 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. +- Switch from specifying which directives should contain the nonce as a separate list, and instead + use a sentinel `NONCE` in the directive itself. 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 +- Add CSP keyword constants in `csp.constants`, e.g. to replace `"'self'"` with `SELF` 3.8 === diff --git a/csp/checks.py b/csp/checks.py index 23c76f0..8a79fa6 100644 --- a/csp/checks.py +++ b/csp/checks.py @@ -3,6 +3,8 @@ from django.conf import settings from django.core.checks import Error +from csp.constants import NONCE + OUTDATED_SETTINGS = [ "CSP_CHILD_SRC", @@ -35,7 +37,6 @@ "CSP_BLOCK_ALL_MIXED_CONTENT", "CSP_REPORT_URI", "CSP_REPORT_TO", - "CSP_INCLUDE_NONCE_IN", ] @@ -55,12 +56,16 @@ def migrate_settings(): if hasattr(settings, "CSP_REPORT_PERCENTAGE"): config["REPORT_PERCENTAGE"] = round(settings.CSP_REPORT_PERCENTAGE * 100) + include_nonce_in = getattr(settings, "CSP_INCLUDE_NONCE_IN", []) + for setting in OUTDATED_SETTINGS: if hasattr(settings, setting): directive = setting[4:].replace("_", "-").lower() value = getattr(settings, setting) if value: config["DIRECTIVES"][directive] = value + if directive in include_nonce_in: + config["DIRECTIVES"][directive].append(NONCE) return config, REPORT_ONLY diff --git a/csp/constants.py b/csp/constants.py index 95242c3..b8e08cf 100644 --- a/csp/constants.py +++ b/csp/constants.py @@ -10,3 +10,18 @@ UNSAFE_HASHES = "'unsafe-hashes'" UNSAFE_INLINE = "'unsafe-inline'" WASM_UNSAFE_EVAL = "'wasm-unsafe-eval'" + + +class Nonce: + _instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self): + return "csp.constants.NONCE" + + +NONCE = Nonce() diff --git a/csp/tests/settings.py b/csp/tests/settings.py index 08b51a8..ffad00d 100644 --- a/csp/tests/settings.py +++ b/csp/tests/settings.py @@ -1,6 +1,9 @@ +from csp.constants import NONCE, SELF + + CONTENT_SECURITY_POLICY = { "DIRECTIVES": { - "include-nonce-in": ["default-src"], + "default-src": [SELF, NONCE], } } diff --git a/csp/tests/test_checks.py b/csp/tests/test_checks.py index b3f6fb0..9c5e8df 100644 --- a/csp/tests/test_checks.py +++ b/csp/tests/test_checks.py @@ -1,6 +1,7 @@ from django.test.utils import override_settings from csp.checks import check_django_csp_lt_4_0, migrate_settings +from csp.constants import NONCE @override_settings( @@ -30,8 +31,7 @@ def test_migrate_settings_report_only(): assert config == { "DIRECTIVES": { "default-src": ["'self'", "example.com"], - "script-src": ["'self'", "example.com", "'unsafe-inline'"], - "include-nonce-in": ["script-src"], + "script-src": ["'self'", "example.com", "'unsafe-inline'", NONCE], } } assert report_only is True diff --git a/csp/tests/test_constants.py b/csp/tests/test_constants.py new file mode 100644 index 0000000..3c3944d --- /dev/null +++ b/csp/tests/test_constants.py @@ -0,0 +1,7 @@ +from csp import constants + + +def test_nonce(): + assert constants.Nonce() == constants.Nonce() + assert constants.NONCE == constants.Nonce() + assert repr(constants.Nonce()) == "csp.constants.NONCE" diff --git a/csp/tests/test_decorators.py b/csp/tests/test_decorators.py index 45e7e93..d75819d 100644 --- a/csp/tests/test_decorators.py +++ b/csp/tests/test_decorators.py @@ -3,7 +3,7 @@ from django.test import RequestFactory from django.test.utils import override_settings -from csp.constants import HEADER, HEADER_REPORT_ONLY +from csp.constants import HEADER, HEADER_REPORT_ONLY, NONCE from csp.decorators import csp, csp_exempt, csp_replace, csp_update from csp.middleware import CSPMiddleware from csp.tests.utils import response @@ -44,12 +44,12 @@ def view_without_decorator(request): 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"]}) + @csp_update({"img-src": ["bar.com", NONCE]}) 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"]} + assert response._csp_update == {"img-src": ["bar.com", NONCE]} mw.process_request(request) assert request.csp_nonce # Here to trigger the nonce creation. mw.process_response(request, response) @@ -77,12 +77,12 @@ def view_without_decorator(request): policy_list = sorted(response[HEADER_REPORT_ONLY].split("; ")) assert policy_list == ["default-src 'self'", "img-src foo.com"] - @csp_update({"img-src": ["bar.com"], "include-nonce-in": ["img-src"]}, REPORT_ONLY=True) + @csp_update({"img-src": ["bar.com", NONCE]}, REPORT_ONLY=True) def view_with_decorator(request): return HttpResponse() response = view_with_decorator(request) - assert response._csp_update_ro == {"img-src": ["bar.com"], "include-nonce-in": ["img-src"]} + assert response._csp_update_ro == {"img-src": ["bar.com", NONCE]} mw.process_request(request) assert request.csp_nonce # Here to trigger the nonce creation. mw.process_response(request, response) diff --git a/csp/tests/test_middleware.py b/csp/tests/test_middleware.py index 90a524a..f8fad35 100644 --- a/csp/tests/test_middleware.py +++ b/csp/tests/test_middleware.py @@ -143,13 +143,3 @@ def test_nonce_regenerated_on_new_request(): mw.process_response(request2, response2) assert nonce1 not in response2[HEADER] assert nonce2 not in response1[HEADER] - - -@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"include-nonce-in": []}}) -def test_no_nonce_when_disabled_by_settings(): - request = rf.get("/") - mw.process_request(request) - nonce = str(request.csp_nonce) - response = HttpResponse() - mw.process_response(request, response) - assert nonce not in response[HEADER] diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py index 041659c..e8077a4 100644 --- a/csp/tests/test_utils.py +++ b/csp/tests/test_utils.py @@ -1,7 +1,7 @@ from django.test.utils import override_settings from django.utils.functional import lazy -from csp.constants import NONE, SELF +from csp.constants import NONCE, NONE, SELF from csp.utils import build_policy, default_config, DEFAULT_DIRECTIVES @@ -44,6 +44,12 @@ def test_empty_policy(): policy_eq("default-src 'self'", policy) +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": None}}) +def test_default_src_none(): + policy = build_policy() + policy_eq("", policy) + + @override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": ["example.com", "example2.com"]}}) def test_default_src(): policy = build_policy() @@ -292,18 +298,19 @@ def test_nonce(): policy_eq("default-src 'self' 'nonce-abc123'", policy) -@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"include-nonce-in": ["script-src", "style-src"]}}) -def test_nonce_include_in(): +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": [SELF], "script-src": [SELF, NONCE], "style-src": [SELF, NONCE]}}) +def test_nonce_in_value(): policy = build_policy(nonce="abc123") policy_eq( - "default-src 'self'; script-src 'nonce-abc123'; style-src 'nonce-abc123'", + "default-src 'self'; script-src 'self' 'nonce-abc123'; style-src 'self' 'nonce-abc123'", policy, ) -def test_nonce_include_in_absent(): +@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": [NONCE]}}) +def test_only_nonce_in_value(): policy = build_policy(nonce="abc123") - policy_eq("default-src 'self' 'nonce-abc123'", policy) + policy_eq("default-src 'nonce-abc123'", policy) def test_boolean_directives(): diff --git a/csp/utils.py b/csp/utils.py index cc645bb..e02ed0e 100644 --- a/csp/utils.py +++ b/csp/utils.py @@ -6,7 +6,7 @@ from django.conf import settings from django.utils.encoding import force_str -from csp.constants import SELF +from csp.constants import NONCE, SELF DEFAULT_DIRECTIVES = { # Fetch Directives @@ -47,8 +47,6 @@ # 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, } @@ -103,28 +101,28 @@ def build_policy(config=None, update=None, replace=None, nonce=None, report_only 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: - policy_parts[key] = "" - elif len(value) and value[0] is False: - pass - else: # directives with many values like src lists - policy_parts[key] = " ".join(value) + # Check for boolean directives. + if len(value) == 1 and isinstance(value[0], bool): + if value[0] is True: + policy_parts[key] = "" + continue + if NONCE in value: + if nonce: + value = [f"'nonce-{nonce}'" if v == NONCE else v for v in value] + else: + # Strip the `NONCE` sentinel value if no nonce is provided. + value = [v for v in value if v != NONCE] + + policy_parts[key] = " ".join(value) if report_uri: report_uri = map(force_str, report_uri) policy_parts["report-uri"] = " ".join(report_uri) - if nonce: - for section in include_nonce_in: - policy = policy_parts.get(section, "") - 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 cbb6c67..2980339 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -121,12 +121,37 @@ policy. configured. .. note:: - The "special" source values of ``'self'``, ``'unsafe-inline'``, ``'unsafe-eval'``, - ``'strict-dynamic'``, ``'none'``, etc. must be quoted! e.g.: ``"default-src": ["'self'"]``. - Without quotes they will not work as intended. + + The CSP keyword values of ``'self'``, ``'unsafe-inline'``, ``'strict-dynamic'``, etc. must be + quoted! e.g.: ``"default-src": ["'self'"]``. Without quotes they will not work as intended. + + New in version 4.0 are CSP keyword constants. Use these to minimize quoting mistakes and typos. + + The following CSP keywords are available: + + * ``NONE`` = ``"'none'"`` + * ``REPORT_SAMPLE`` = ``"'report-sample'"`` + * ``SELF`` = ``"'self'"`` + * ``STRICT_DYNAMIC`` = ``"'strict-dynamic'"`` + * ``UNSAFE_ALLOW_REDIRECTS`` = ``"'unsafe-allow-redirects'"`` + * ``UNSAFE_EVAL`` = ``"'unsafe-eval'"`` + * ``UNSAFE_HASHES`` = ``"'unsafe-hashes'"`` + * ``UNSAFE_INLINE`` = ``"'unsafe-inline'"`` + * ``WASM_UNSAFE_EVAL`` = ``"'wasm-unsafe-eval'"`` + + Example usage: + + .. code-block:: python - Consider using the ``csp.constants`` module to get these values to help avoiding quoting - errors or typos, e.g., ``from csp.constants import SELF, STRICT_DYNAMIC``. + from csp.constants import SELF, STRICT_DYNAMIC + + CONTENT_SECURITY_POLICY = { + "DIRECTIVES": { + "default-src": [SELF, "cdn.example.net"], + "script-src": [SELF, STRICT_DYNAMIC], + "style-src": [SELF], + } + } .. note:: Deprecated features of CSP in general have been moved to the bottom of this list. @@ -248,17 +273,6 @@ policy. 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. - Deprecated CSP settings ----------------------- diff --git a/docs/migration-guide.rst b/docs/migration-guide.rst index 96f81b4..5320c8f 100644 --- a/docs/migration-guide.rst +++ b/docs/migration-guide.rst @@ -87,12 +87,14 @@ The new settings would be: .. code-block:: python + from csp.constants import SELF + 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"], + "default-src": [SELF, "*.example.com"], + "script-src": [SELF, "js.cdn.com/example/"], + "img-src": [SELF, "data:", "example.com"], }, } @@ -101,6 +103,31 @@ The new settings would be: 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_INCLUDE_NONCE_IN`` setting, this has been removed in the new settings + format. + + **Previously:** You could use the ``CSP_INCLUDE_NONCE_IN`` setting to specify which directives in + your Content Security Policy (CSP) should include a nonce. + + **Now:** You can include a nonce in any directive by adding the ``NONCE`` constant from the + ``csp.constants`` module to the list of sources for that directive. + + For example, if you had ``CSP_INCLUDE_NONCE_IN = ["script-src"]``, this should be updated to + include the `NONCE` sentinel in the `script-src` directive values: + + .. code-block:: python + + from csp.constants import NONCE, SELF + + CONTENT_SECURITY_POLICY = { + "DIRECTIVES": { + "script-src": [SELF, NONCE], + # ... + }, + } + .. note:: If you were using the ``CSP_REPORT_PERCENTAGE`` setting, this should be updated to be an integer @@ -142,10 +169,11 @@ policy should be enforced or only report violations. For example: .. code-block:: python + from csp.constants import SELF from csp.decorators import csp - @csp({"default-src": ["'self'"]}, REPORT_ONLY=True) + @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`` diff --git a/docs/nonce.rst b/docs/nonce.rst index 12f1351..0cf07ab 100644 --- a/docs/nonce.rst +++ b/docs/nonce.rst @@ -1,7 +1,7 @@ ============================== Using the generated CSP nonce ============================== -When ``include-nonce-in`` is configured, the nonce value is returned in the CSP headers **if it is +When ``NONCE`` is included in a directive, 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. @@ -37,8 +37,8 @@ appended to any script element like so - var hello="world"; -Assuming the ``include-nonce-in`` list contains the ``script-src`` directive, this will result in -the above script being allowed. +Assuming the ``NONCE`` sentinel is included in the ``script-src`` directive, this will result in the +above script being allowed. .. Note::