Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move to NONCE sentinel instead of 'include-nonce-in' #223

Merged
merged 1 commit into from
Jun 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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],
}
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes were meant for the last commit adding constants but got added to the wrong commit locally. Oops.

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