From 9302734a56f0c2ecbd9fe6ed40c21ab4bbc9cf40 Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Mon, 24 Feb 2025 16:45:53 -0600 Subject: [PATCH 1/3] Allow reading nonce if it was included in header If the nonce was generated before the CSP headers were set, then allow reading it with request.csp_nonce. If the CSP headers were set with no nonce, then continue raising CSPNonceError when reading it as a string. If read as a boolean, then return False. This will allow other middleware like django-debug-toolbar to alter the response after the CSP middleware runs. --- csp/middleware.py | 15 +++++++++++++-- csp/tests/test_middleware.py | 15 +++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/csp/middleware.py b/csp/middleware.py index 00ad21a..d7fd2f2 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -29,6 +29,11 @@ class PolicyParts: nonce: str | None = None +class FalseLazyObject(SimpleLazyObject): + def __bool__(self) -> bool: + return False + + class CSPMiddleware(MiddlewareMixin): """ Implements the Content-Security-Policy response header, which @@ -95,7 +100,8 @@ def process_response(self, request: HttpRequest, response: HttpResponseBase) -> # Once we've written the header, accessing the `request.csp_nonce` will no longer trigger # the nonce to be added to the header. Instead we throw an error here to catch this since # this has security implications. - setattr(request, "csp_nonce", SimpleLazyObject(self._csp_nonce_post_response)) + if getattr(request, "_csp_nonce", None) is None: + setattr(request, "csp_nonce", FalseLazyObject(self._csp_nonce_post_response)) return response @@ -109,7 +115,12 @@ def build_policy_ro(self, request: HttpRequest, response: HttpResponseBase) -> s policy_parts_ro = self.get_policy_parts(request=request, response=response, report_only=True) return build_policy(**asdict(policy_parts_ro), report_only=True) - def get_policy_parts(self, request: HttpRequest, response: HttpResponseBase, report_only: bool = False) -> PolicyParts: + def get_policy_parts( + self, + request: HttpRequest, + response: HttpResponseBase, + report_only: bool = False, + ) -> PolicyParts: if report_only: config = getattr(response, "_csp_config_ro", None) update = getattr(response, "_csp_update_ro", None) diff --git a/csp/tests/test_middleware.py b/csp/tests/test_middleware.py index fd26ba7..84e8370 100644 --- a/csp/tests/test_middleware.py +++ b/csp/tests/test_middleware.py @@ -160,10 +160,21 @@ def test_nonce_regenerated_on_new_request() -> None: assert nonce2 not in response1[HEADER] -def test_nonce_attribute_error() -> None: - # Test `CSPNonceError` is raised when accessing the nonce after the response has been processed. +def test_no_nonce_access_after_middleware_is_attribute_error() -> None: + # Test `CSPNonceError` is raised when accessing an unset nonce after the response has been processed. request = rf.get("/") mw.process_request(request) mw.process_response(request, HttpResponse()) + assert bool(getattr(request, "csp_nonce", True)) is False with pytest.raises(CSPNonceError): str(getattr(request, "csp_nonce")) + + +def test_set_nonce_access_after_middleware_is_ok() -> None: + # Test accessing a set nonce after the response has been processed is OK. + request = rf.get("/") + mw.process_request(request) + nonce = str(getattr(request, "csp_nonce")) + mw.process_response(request, HttpResponse()) + assert bool(getattr(request, "csp_nonce", False)) is True + assert str(getattr(request, "csp_nonce")) == nonce From 069f72b48c6392c3a7b19d1da8141691589a99ac Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Thu, 27 Feb 2025 12:28:22 -0600 Subject: [PATCH 2/3] Add CSPMiddlewareAlwaysGenerateNonce This variant middleware always generates the nonce. This is useful when a process that runs after the middleware needs the nonce. One example is the middleware used by django-debug-toolbar (DDT). It needs to be defined early in the MIDDLEWARE list that it can inject HTML, CSS, and JavaScript after the response has been generated. DDT users could use this middleware to ensure the CSP nonce is always available for its asset. --- csp/middleware.py | 18 +++++++++++++++ csp/tests/test_middleware.py | 41 +++++++++++++++++++++++++++++++++- docs/installation.rst | 7 +++++- docs/nonce.rst | 43 +++++++++++++++++++++++++++++++----- 4 files changed, 101 insertions(+), 8 deletions(-) diff --git a/csp/middleware.py b/csp/middleware.py index d7fd2f2..9614ab4 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -45,6 +45,8 @@ class CSPMiddleware(MiddlewareMixin): Can be customised by subclassing and extending the get_policy_parts method. """ + always_generate_nonce = False + def _make_nonce(self, request: HttpRequest) -> str: # Ensure that any subsequent calls to request.csp_nonce return the same value stored_nonce = getattr(request, "_csp_nonce", None) @@ -63,6 +65,8 @@ 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)) + if self.always_generate_nonce: + self._make_nonce(request) def process_response(self, request: HttpRequest, response: HttpResponseBase) -> HttpResponseBase: # Check for debug view @@ -133,3 +137,17 @@ def get_policy_parts( nonce = getattr(request, "_csp_nonce", None) return PolicyParts(config, update, replace, nonce) + + +class CSPMiddlewareAlwaysGenerateNonce(CSPMiddleware): + """ + A middleware variant that always generates a nonce. + + This is useful when a later process needs a nonce, whether or not the wrapped + request uses a nonce. One example is django-debug-toolbar (DDT). The DDT + middleware needs to be high in the MIDDLEWARE list, so it can inject its + HTML, CSS, and JS describing the request generation. DDT users can use + this middleware instead of CSPMiddleware. + """ + + always_generate_nonce = True diff --git a/csp/tests/test_middleware.py b/csp/tests/test_middleware.py index 84e8370..aad2183 100644 --- a/csp/tests/test_middleware.py +++ b/csp/tests/test_middleware.py @@ -10,7 +10,7 @@ from csp.constants import HEADER, HEADER_REPORT_ONLY, SELF from csp.exceptions import CSPNonceError -from csp.middleware import CSPMiddleware +from csp.middleware import CSPMiddleware, CSPMiddlewareAlwaysGenerateNonce from csp.tests.utils import response mw = CSPMiddleware(response()) @@ -178,3 +178,42 @@ def test_set_nonce_access_after_middleware_is_ok() -> None: mw.process_response(request, HttpResponse()) assert bool(getattr(request, "csp_nonce", False)) is True assert str(getattr(request, "csp_nonce")) == nonce + + +def test_csp_always_nonce_middleware_has_nonce() -> None: + request = rf.get("/") + mw_agn = CSPMiddlewareAlwaysGenerateNonce(response()) + mw_agn.process_request(request) + resp = HttpResponse() + mw_agn.process_response(request, resp) + nonce = str(getattr(request, "csp_nonce")) + assert nonce in resp[HEADER] + + +def test_csp_always_nonce_middleware_nonce_regenerated_on_new_request() -> None: + mw_agn = CSPMiddlewareAlwaysGenerateNonce(response()) + request1 = rf.get("/") + request2 = rf.get("/") + mw_agn.process_request(request1) + mw_agn.process_request(request2) + nonce1 = str(getattr(request1, "csp_nonce")) + nonce2 = str(getattr(request2, "csp_nonce")) + assert nonce1 != nonce2 + + response1 = HttpResponse() + response2 = HttpResponse() + mw_agn.process_response(request1, response1) + mw_agn.process_response(request2, response2) + assert nonce1 not in response2[HEADER] + assert nonce2 not in response1[HEADER] + + +def test_csp_always_nonce_middleware_access_after_middleware_is_ok() -> None: + # Test accessing a set nonce after the response has been processed is OK. + request = rf.get("/") + mw_agn = CSPMiddlewareAlwaysGenerateNonce(response()) + mw_agn.process_request(request) + nonce = str(getattr(request, "csp_nonce")) + mw_agn.process_response(request, HttpResponse()) + assert bool(getattr(request, "csp_nonce", False)) is True + assert str(getattr(request, "csp_nonce")) == nonce diff --git a/docs/installation.rst b/docs/installation.rst index f593de9..ff3a99a 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -31,6 +31,11 @@ to ``MIDDLEWARE``, like so: # ... ) -Note: Middleware order does not matter unless you have other middleware modifying the CSP header. +.. Note:: + + Middleware order does not matter unless you have other middleware modifying + the CSP header, or requires CSP features like a nonce. See + :ref:`Using the generated CSP nonce` for further advice on middleware order. + That should do it! Go on to :ref:`configuring CSP `. diff --git a/docs/nonce.rst b/docs/nonce.rst index 95cea28..b29630a 100644 --- a/docs/nonce.rst +++ b/docs/nonce.rst @@ -48,13 +48,44 @@ 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 ``request.csp_nonce`` is accessed **after** the response has been processed by the middleware, - a ``csp.exceptions.CSPNonceError`` will be raised. + 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``. - Middleware that accesses ``request.csp_nonce`` **must be placed after** - ``csp.middleware.CSPMiddleware`` in the ``MIDDLEWARE`` setting. This ensures that - ``CSPMiddleware`` properly processes the response and includes the nonce in the CSP header before - other middleware attempts to use it. + 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: + +* The middleware can be placed after ``csp.middleware.CSPMiddleware`` in the ``MIDDLEWARE`` setting. + This ensures that the middleware generates the nonce before ``CSPMiddleware`` writes the CSP header. +* Use the alternate ``csp.middleware.CSPMiddlewareAlwaysGenerateNonce`` middleware, which always + generates a nonce and includes it in the CSP header. +* Add a later middleware that accesses the nonce. For example, this function: + +.. code-block:: python + + def init_csp_nonce_middleware(get_response): + def middleware(request): + getattr(request, "csp_nonce", None) + return get_response(request) + + return middleware + +could be added to the ``MIDDLEWARE`` list: + +.. code-block:: python + + MIDDLEWARE = ( + "my.middleware.ThatUsesCSPNonce", + # ... + "csp.middleware.CSPMiddleware", + # ... + "my.middleware.init_csp_nonce_middleware", + ) ``Context Processor`` ===================== From 23dd7bde04f771bed825ddd89b66daa38f1e1a77 Mon Sep 17 00:00:00 2001 From: John Whitlock Date: Fri, 28 Feb 2025 09:52:09 -0600 Subject: [PATCH 3/3] Fix docstring --- csp/middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csp/middleware.py b/csp/middleware.py index 9614ab4..ab2ee22 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -146,7 +146,7 @@ class CSPMiddlewareAlwaysGenerateNonce(CSPMiddleware): This is useful when a later process needs a nonce, whether or not the wrapped request uses a nonce. One example is django-debug-toolbar (DDT). The DDT middleware needs to be high in the MIDDLEWARE list, so it can inject its - HTML, CSS, and JS describing the request generation. DDT users can use + HTML, CSS, and JS describing the response generation. DDT users can use this middleware instead of CSPMiddleware. """