Skip to content
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
2 changes: 1 addition & 1 deletion csp/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def parse(self, parser: Parser) -> nodes.Node:
def _render_script(self, caller: Callable[[], str], **kwargs: Any) -> str:
ctx = kwargs.pop("ctx")
request = ctx.get("request")
kwargs["nonce"] = request.csp_nonce
kwargs["nonce"] = str(request.csp_nonce)
kwargs["content"] = caller().strip()

return build_script_tag(**kwargs)
16 changes: 11 additions & 5 deletions csp/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from django.conf import settings
from django.utils.deprecation import MiddlewareMixin
from django.utils.functional import SimpleLazyObject
from django.utils.functional import SimpleLazyObject, empty

from csp.constants import HEADER, HEADER_REPORT_ONLY
from csp.exceptions import CSPNonceError
Expand All @@ -29,9 +29,15 @@ class PolicyParts:
nonce: str | None = None


class FalseLazyObject(SimpleLazyObject):
class CheckableLazyObject(SimpleLazyObject):
"""A SimpleLazyObject where bool(obj) returns True if no longer lazy"""

def __bool__(self) -> bool:
return False
"""
If the wrapped function has been evaluated, return True.
If the wrapped function has not been evalated, return False.
"""
return getattr(self, "_wrapped") is not empty


class CSPMiddleware(MiddlewareMixin):
Expand Down Expand Up @@ -64,7 +70,7 @@ def _csp_nonce_post_response() -> None:

def process_request(self, request: HttpRequest) -> None:
nonce = partial(self._make_nonce, request)
setattr(request, "csp_nonce", SimpleLazyObject(nonce))
setattr(request, "csp_nonce", CheckableLazyObject(nonce))
if self.always_generate_nonce:
self._make_nonce(request)

Expand Down Expand Up @@ -105,7 +111,7 @@ def process_response(self, request: HttpRequest, response: HttpResponseBase) ->
# the nonce to be added to the header. Instead we throw an error here to catch this since
# this has security implications.
if getattr(request, "_csp_nonce", None) is None:
setattr(request, "csp_nonce", FalseLazyObject(self._csp_nonce_post_response))
setattr(request, "csp_nonce", CheckableLazyObject(self._csp_nonce_post_response))

return response

Expand Down
2 changes: 1 addition & 1 deletion csp/templatetags/csp.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def _get_token_value(self, t: FilterExpression) -> str | None:
def render(self, context: Context) -> str:
output = self.nodelist.render(context).strip()
request = context.get("request")
nonce = getattr(request, "csp_nonce", "")
nonce = str(getattr(request, "csp_nonce", ""))
self.script_attrs.update({"nonce": nonce, "content": output})

return build_script_tag(**self.script_attrs)
117 changes: 115 additions & 2 deletions csp/tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,39 @@
HttpResponseNotFound,
HttpResponseServerError,
)
from django.template import Context, Template, engines
from django.test import RequestFactory
from django.test.utils import override_settings

import pytest

from csp.constants import HEADER, HEADER_REPORT_ONLY, SELF
from csp.exceptions import CSPNonceError
from csp.middleware import CSPMiddleware, CSPMiddlewareAlwaysGenerateNonce
from csp.middleware import (
CheckableLazyObject,
CSPMiddleware,
CSPMiddlewareAlwaysGenerateNonce,
)
from csp.tests.utils import response

mw = CSPMiddleware(response())
rf = RequestFactory()


def test_checkable_lazy_object() -> None:
def generate_value() -> str:
return "generated"

lazy = CheckableLazyObject(generate_value)

# Before wrapped object is initiated, lazy is falsy
assert bool(lazy) is False

# After str(lazy) calls generate_value, lazy is truthy
assert str(lazy) == "generated"
assert bool(lazy) is True


def test_add_header() -> None:
request = rf.get("/")
response = HttpResponse()
Expand Down Expand Up @@ -73,7 +92,8 @@ def test_report_only() -> None:
response = HttpResponse()
mw.process_response(request, response)
assert HEADER not in response
assert HEADER + "-Report-Only" in response
assert HEADER_REPORT_ONLY in response
assert response[HEADER_REPORT_ONLY] == "default-src 'self'"


def test_dont_replace() -> None:
Expand Down Expand Up @@ -133,6 +153,98 @@ def test_nonce_created_when_accessed() -> None:
response = HttpResponse()
mw.process_response(request, response)
assert nonce in response[HEADER]
assert response[HEADER] == f"default-src 'self' 'nonce-{nonce}'"


def test_nonce_is_false_before_access_and_true_after() -> None:
request = rf.get("/")
mw.process_request(request)
assert bool(getattr(request, "csp_nonce")) is False
nonce = str(getattr(request, "csp_nonce"))
assert bool(getattr(request, "csp_nonce")) is True

response = HttpResponse()
mw.process_response(request, response)
assert bool(getattr(request, "csp_nonce")) is True
assert getattr(request, "csp_nonce") == nonce


def test_nonce_conditional_in_django_template() -> None:
"""An unset nonce is Falsy in a template context"""

template = Template(
"""
{% if request.csp_nonce %}
The CSP nonce is {{ request.csp_nonce }}.
{% else %}
The CSP nonce is not set.
{% endif %}
"""
)
request = rf.get("/")
context = Context({"request": request})

mw.process_request(request)
rendered_unset = template.render(context).strip()
assert rendered_unset == "The CSP nonce is not set."

nonce = str(getattr(request, "csp_nonce"))
rendered_set = template.render(context).strip()
assert rendered_set == f"The CSP nonce is {nonce}."


def test_nonce_usage_in_django_template() -> None:
"""Reading a nonce in a template context generates the nonce"""

template = Template("The CSP nonce is {{ request.csp_nonce }}.")
request = rf.get("/")
context = Context({"request": request})

mw.process_request(request)
nonce = getattr(request, "csp_nonce", None)
assert bool(nonce) is False
rendered = template.render(context)
assert bool(nonce) is True
assert rendered == f"The CSP nonce is {nonce}."


def test_nonce_conditional_in_jinja2_template() -> None:
"""An unset nonce is Falsy in a template context"""

template = engines["jinja2"].from_string(
"""
{% if request.csp_nonce %}
The CSP nonce is {{ request.csp_nonce }}.
{% else %}
The CSP nonce is not set.
{% endif %}
"""
)
request = rf.get("/")
context = {"request": request}

mw.process_request(request)
rendered_unset = template.render(context).strip()
assert rendered_unset == "The CSP nonce is not set."

nonce = str(getattr(request, "csp_nonce"))
rendered_set = template.render(context).strip()
assert rendered_set == f"The CSP nonce is {nonce}."


def test_nonce_usage_in_jinja2_template() -> None:
"""Reading a nonce in a template context generates the nonce"""

template = engines["jinja2"].from_string("The CSP nonce is {{ request.csp_nonce }}.")
request = rf.get("/")
context = {"request": request}

mw.process_request(request)
nonce = getattr(request, "csp_nonce", None)
assert bool(nonce) is False
rendered = template.render(context)
assert bool(nonce) is True
assert rendered == f"The CSP nonce is {nonce}."


def test_no_nonce_when_not_accessed() -> None:
Expand All @@ -141,6 +253,7 @@ def test_no_nonce_when_not_accessed() -> None:
response = HttpResponse()
mw.process_response(request, response)
assert "nonce-" not in response[HEADER]
assert response[HEADER] == "default-src 'self'"


def test_nonce_regenerated_on_new_request() -> None:
Expand Down
24 changes: 15 additions & 9 deletions docs/nonce.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,21 @@ above script being allowed.
- ``request.csp_nonce`` is accessed during the request lifecycle, after the middleware
processes the request but before it processes the response.

If the nonce was not generated and included in the CSP header, then accessing ``request.csp_nonce``
could indicate a programming error. To help identify the error, testing
(like ``bool(request.csp_nonce)``) will evaluate to ``False``, and reading
(like ``str(request.csp_nonce)``) will raise a
``csp.exceptions.CSPNonceError``.
The ``csp.middleware.CSPMiddleware`` will include the nonce in the CSP
header as it processes the response, but only if it was generated by code
reading ``str(request.csp_nonce)``.

If code reads an un-generated ``request.csp_nonce`` after the middleware
processes the response, it is probably a programming error. In this case,
attempting to read the nonce (like ``str(request.csp_nonce)``) will raise a
``csp.exceptions.CSPNonceError``. If the nonce was generated and included in
the CSP header, then reading ``request.csp_nonce`` is safe.

It is always safe to test ``request.csp_nonce``, such as
``bool(request.csp_nonce)`` or in a conditional like ``if request.csp_nonce:
...``. This will return ``True`` if the nonce was accessed and generated, and
``False`` if not acccesed or generated yet.

If the nonce was generated and included in the CSP header, then accessing ``request.csp_nonce``
is safe. Testing (like ``bool(request.csp_nonce)``) will evaluate to
``True``, and reading (like ``str(request.csp_nonce)``) will return the nonce.

If other middleware or a later process needs to access ``request.csp_nonce``, then there are a few options:

Expand All @@ -70,7 +76,7 @@ If other middleware or a later process needs to access ``request.csp_nonce``, th

def init_csp_nonce_middleware(get_response):
def middleware(request):
getattr(request, "csp_nonce", None)
str(getattr(request, "csp_nonce", None))
return get_response(request)

return middleware
Expand Down