diff --git a/csp/middleware.py b/csp/middleware.py index 00ad21a..ab2ee22 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 @@ -40,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) @@ -58,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 @@ -95,7 +104,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 +119,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) @@ -122,3 +137,17 @@ def get_policy_parts(self, request: HttpRequest, response: HttpResponseBase, rep 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 response 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 fd26ba7..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()) @@ -160,10 +160,60 @@ 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 + + +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`` =====================