diff --git a/csp/extensions/__init__.py b/csp/extensions/__init__.py index 8ddc782..eef53d4 100644 --- a/csp/extensions/__init__.py +++ b/csp/extensions/__init__.py @@ -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) diff --git a/csp/middleware.py b/csp/middleware.py index ab2ee22..d0bbbe6 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -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 @@ -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): @@ -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) @@ -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 diff --git a/csp/templatetags/csp.py b/csp/templatetags/csp.py index 415bfcf..cce6e26 100644 --- a/csp/templatetags/csp.py +++ b/csp/templatetags/csp.py @@ -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) diff --git a/csp/tests/test_middleware.py b/csp/tests/test_middleware.py index aad2183..6502e8c 100644 --- a/csp/tests/test_middleware.py +++ b/csp/tests/test_middleware.py @@ -3,6 +3,7 @@ HttpResponseNotFound, HttpResponseServerError, ) +from django.template import Context, Template, engines from django.test import RequestFactory from django.test.utils import override_settings @@ -10,13 +11,31 @@ 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() @@ -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: @@ -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: @@ -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: diff --git a/docs/nonce.rst b/docs/nonce.rst index b29630a..bda9afb 100644 --- a/docs/nonce.rst +++ b/docs/nonce.rst @@ -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: @@ -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