Skip to content

Commit

Permalink
Move to NONCE sentinel instead of 'include-nonce-in'
Browse files Browse the repository at this point in the history
  • Loading branch information
robhudson committed Jun 7, 2024
1 parent 193c0f5 commit 6dd0da2
Show file tree
Hide file tree
Showing 13 changed files with 134 additions and 64 deletions.
3 changes: 3 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -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
===
Expand Down
7 changes: 6 additions & 1 deletion csp/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -35,7 +37,6 @@
"CSP_BLOCK_ALL_MIXED_CONTENT",
"CSP_REPORT_URI",
"CSP_REPORT_TO",
"CSP_INCLUDE_NONCE_IN",
]


Expand All @@ -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

Expand Down
15 changes: 15 additions & 0 deletions csp/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
5 changes: 4 additions & 1 deletion csp/tests/settings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from csp.constants import NONCE, SELF


CONTENT_SECURITY_POLICY = {
"DIRECTIVES": {
"include-nonce-in": ["default-src"],
"default-src": [SELF, NONCE],
}
}

Expand Down
4 changes: 2 additions & 2 deletions csp/tests/test_checks.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions csp/tests/test_constants.py
Original file line number Diff line number Diff line change
@@ -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"
10 changes: 5 additions & 5 deletions csp/tests/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 0 additions & 10 deletions csp/tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
19 changes: 13 additions & 6 deletions csp/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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():
Expand Down
30 changes: 14 additions & 16 deletions csp/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}


Expand Down Expand Up @@ -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()])


Expand Down
46 changes: 30 additions & 16 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
-----------------------
Expand Down
36 changes: 32 additions & 4 deletions docs/migration-guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
}
Expand All @@ -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
Expand Down Expand Up @@ -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``
Expand Down
Loading

0 comments on commit 6dd0da2

Please sign in to comment.