From 2f4596c1a72a8e1c5c901bb89abb4cba84b3e0ff Mon Sep 17 00:00:00 2001 From: Rob Hudson Date: Thu, 6 Jun 2024 15:33:50 -0700 Subject: [PATCH] Add constants for CSP keywords This helps avoid potential errors introduced by incorrectly quoting CSP keywords. --- csp/constants.py | 10 ++++++ csp/tests/test_middleware.py | 6 ++-- csp/tests/test_utils.py | 3 +- csp/utils.py | 3 +- docs/configuration.rst | 52 +++++++++++++++++++-------- docs/contributing.rst | 12 +++++-- docs/decorators.rst | 70 ++++++++++++++++++++++++++---------- 7 files changed, 116 insertions(+), 40 deletions(-) diff --git a/csp/constants.py b/csp/constants.py index 8cf10bc..95242c3 100644 --- a/csp/constants.py +++ b/csp/constants.py @@ -1,2 +1,12 @@ HEADER = "Content-Security-Policy" HEADER_REPORT_ONLY = "Content-Security-Policy-Report-Only" + +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'" diff --git a/csp/tests/test_middleware.py b/csp/tests/test_middleware.py index e52971a..90a524a 100644 --- a/csp/tests/test_middleware.py +++ b/csp/tests/test_middleware.py @@ -6,7 +6,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, SELF from csp.middleware import CSPMiddleware from csp.tests.utils import response @@ -23,7 +23,7 @@ def test_add_header(): @override_settings( CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": ["example.com"]}}, - CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"default-src": ["'self'"]}}, + CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"default-src": [SELF]}}, ) def test_both_headers(): request = rf.get("/") @@ -51,7 +51,7 @@ def text_exclude(): @override_settings( CONTENT_SECURITY_POLICY=None, - CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"default-src": ["'self'"]}}, + CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"default-src": [SELF]}}, ) def test_report_only(): request = rf.get("/") diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py index 188fa3b..041659c 100644 --- a/csp/tests/test_utils.py +++ b/csp/tests/test_utils.py @@ -1,6 +1,7 @@ from django.test.utils import override_settings from django.utils.functional import lazy +from csp.constants import NONE, SELF from csp.utils import build_policy, default_config, DEFAULT_DIRECTIVES @@ -182,7 +183,7 @@ def test_replace_missing_setting(): def test_config(): - policy = build_policy(config={"default-src": ["'none'"], "img-src": ["'self'"]}) + policy = build_policy(config={"default-src": [NONE], "img-src": [SELF]}) policy_eq("default-src 'none'; img-src 'self'", policy) diff --git a/csp/utils.py b/csp/utils.py index cf62ee1..cc645bb 100644 --- a/csp/utils.py +++ b/csp/utils.py @@ -6,12 +6,13 @@ from django.conf import settings from django.utils.encoding import force_str +from csp.constants import SELF DEFAULT_DIRECTIVES = { # Fetch Directives "child-src": None, "connect-src": None, - "default-src": ["'self'"], + "default-src": [SELF], "script-src": None, "script-src-attr": None, "script-src-elem": None, diff --git a/docs/configuration.rst b/docs/configuration.rst index 834b417..cbb6c67 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -43,12 +43,14 @@ a more slightly strict policy and is used to test the policy without breaking th .. code-block:: python + from csp.constants import NONE, SELF + CONTENT_SECURITY_POLICY = { "EXCLUDE_URL_PREFIXES": ["/excluded-path/"], "DIRECTIVES": { - "default-src": ["'self'", "cdn.example.net"], - "frame-ancestors": ["'self'"], - "form-action": ["'self'"], + "default-src": [SELF, "cdn.example.net"], + "frame-ancestors": [SELF], + "form-action": [SELF], "report-uri": "/csp-report/", }, } @@ -56,18 +58,39 @@ a more slightly strict policy and is used to test the policy without breaking th 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'"], + "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/", }, } +.. note:: + + In the above example, the constant ``NONE`` is converted to the CSP keyword ``"'none'"`` and + is distinct from Python's ``None`` value. The CSP keyword ``'none'`` is a special value that + signifies that you do not want any sources for this directive. The ``None`` value is a + Python keyword that represents the absence of a value and when used as the value of a directive, + it will remove the directive from the policy. + + This is useful when using the ``@csp_replace`` decorator to effectively clear a directive from + the base configuration as defined in the settings. For example, if the Django settings the + ``frame-ancestors`` directive is set to a list of sources and you want to remove the + ``frame-ancestors`` directive from the policy for this view: + + .. code-block:: python + + from csp.decorators import csp_replace + + + @csp_replace({"frame-ancestors": None}) + def my_view(request): ... + Policy Settings =============== @@ -86,8 +109,6 @@ policy. Scripting flaw on, e.g., ``excluded-page/`` can therefore be leveraged to access everything on the same origin. - # TODO: I can't find any documentation on the above warning. - ``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 @@ -101,8 +122,11 @@ policy. .. 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. + ``'strict-dynamic'``, ``'none'``, etc. must be quoted! e.g.: ``"default-src": ["'self'"]``. + Without quotes they will not work as intended. + + 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``. .. note:: Deprecated features of CSP in general have been moved to the bottom of this list. diff --git a/docs/contributing.rst b/docs/contributing.rst index 3c98b24..e48df57 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -25,16 +25,22 @@ Patches fixing bugs should include regression tests (ideally tests that fail without the rest of the patch). Patches adding new features should test those features thoroughly. -To run the tests, install the requirements (probably into a virtualenv_):: +To run the tests, install the requirements (probably into a virtualenv_): + +.. code-block:: bash pip install -e . pip install -e ".[tests]" -Then just `pytest`_ to run the tests:: +Then just `pytest`_ to run the tests: + +.. code-block:: bash pytest -To run the tests with coverage and get a report, use the following command:: +To run the tests with coverage and get a report, use the following command: + +.. code-block:: bash pytest --cov=csp --cov-config=.coveragerc diff --git a/docs/decorators.rst b/docs/decorators.rst index 6c36378..5d3091a 100644 --- a/docs/decorators.rst +++ b/docs/decorators.rst @@ -7,6 +7,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. +All decorators take an optional keyword argument, ``REPORT_ONLY``, which defaults to ``False``. If +set to ``True``, the decorator will update the report-only policy instead of the enforced policy. ``@csp_exempt`` =============== @@ -14,22 +16,26 @@ need to expand or change the policy. django-csp includes four decorators to help Using the ``@csp_exempt`` decorator disables the CSP header on a given view. -:: +.. code-block:: python from csp.decorators import csp_exempt + # Will not have a CSP header. @csp_exempt() def myview(request): return render(...) + # 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``:: +or ``_csp_exempt_ro`` attribute on the response to ``True``: + +.. code-block:: python # Also will not have a CSP header. def myview(request): @@ -45,6 +51,7 @@ The ``@csp_update`` header allows you to **append** values to the source lists s 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':: @@ -55,15 +62,17 @@ decorator excpects a single dictionary argument, where the keys are the directiv 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. -:: +.. code-block:: python from csp.decorators import csp_update + # 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): @@ -77,41 +86,66 @@ The ``@csp_replace`` decorator allows you to **replace** a source list specified 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``:: +The arguments and values are the same as ``@csp_update``: + +.. code-block:: python from csp.decorators import csp_replace + # 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(...) +The ``csp_replace`` decorator can also be used to remove a directive from the policy by setting the +value to ``None``. For example, if the ``frame-ancestors`` directive is set in the Django settings +and you want to remove the ``frame-ancestors`` directive from the policy for this view: + +.. code-block:: python + + from csp.decorators import csp_replace + + + @csp_replace({"frame-ancestors": None}) + def myview(request): + return render(...) + ``@csp`` ======== If you need to set the entire policy on a view, ignoring all the settings, you can use the ``@csp`` -decorator. 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:: +decorator. This can be stacked to update both the enforced policy and the report-only policy if both +are in use, as shown below. +.. code-block:: python + + from csp.constants import SELF, UNSAFE_INLINE from csp.decorators import csp - @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 - }) + + @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(...)