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 6, 2024
1 parent 29c175c commit 465bb4b
Show file tree
Hide file tree
Showing 12 changed files with 128 additions and 63 deletions.
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
32 changes: 29 additions & 3 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,30 @@ 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:

.. code-block:: python
from csp.constants import NONCE
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
6 changes: 3 additions & 3 deletions docs/nonce.rst
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -37,8 +37,8 @@ appended to any script element like so -
var hello="world";
</script>

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::

Expand Down

0 comments on commit 465bb4b

Please sign in to comment.