Repository: mozilla/django-csp

Issue:
Footgun when accessing nonce value in a middleware
`django-csp` only generates the nonce value (and includes it the header) the first time that `.csp_nonce` is accessed on a request. However, if that's accessed first in a middleware _after_ `django-csp` has completed, it'll be generated but not included in the header, because `django-csp` has already finished processing and sendings its headers. Slightly more concretely, if we have:

```python
MIDDLEWARE = [
    ...
    "foo.bar",
    "csp.middleware.CSPMiddleware",
    ...
]
```

And foo looks something like:

```python
def bar(get_response):
    def middleware(request):
        response = get_response(request)
        response = inject_script_into_response(response, nonce=request.csp_nonce)
        return response
    return middleware
```

The script will correctly have a CSP nonce in it, but the CSP header won't necessarily include the nonce because of the position of CSPMiddleware.

Obviously the fix is on the user here and "foo.bar" should be positioned after the CSP middleware, however I can attest this took a while to track down. I think it would be better to make this a hard error if a CSP nonce doesn't already exist.

Discussion:

Patch:
diff --git a/csp/exceptions.py b/csp/exceptions.py
new file mode 100644
index 0000000..c07cb6e
--- /dev/null
+++ b/csp/exceptions.py
@@ -0,0 +1,2 @@
+class CSPNonceError(Exception):
+    pass
diff --git a/csp/middleware.py b/csp/middleware.py
index 31a857c..00ad21a 100644
--- a/csp/middleware.py
+++ b/csp/middleware.py
@@ -13,6 +13,7 @@ from django.utils.deprecation import MiddlewareMixin
 from django.utils.functional import SimpleLazyObject
 
 from csp.constants import HEADER, HEADER_REPORT_ONLY
+from csp.exceptions import CSPNonceError
 from csp.utils import DIRECTIVES_T, build_policy
 
 if TYPE_CHECKING:
@@ -48,6 +49,12 @@ class CSPMiddleware(MiddlewareMixin):
         setattr(request, "_csp_nonce", nonce)
         return nonce
 
+    @staticmethod
+    def _csp_nonce_post_response() -> None:
+        raise CSPNonceError(
+            "The 'csp_nonce' attribute is not available after the CSP header has been written. Consider adjusting your MIDDLEWARE order."
+        )
+
     def process_request(self, request: HttpRequest) -> None:
         nonce = partial(self._make_nonce, request)
         setattr(request, "csp_nonce", SimpleLazyObject(nonce))
@@ -85,6 +92,11 @@ class CSPMiddleware(MiddlewareMixin):
             if no_header and is_not_exempt and is_not_excluded:
                 response[HEADER_REPORT_ONLY] = csp_ro
 
+        # 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))
+
         return response
 
     def build_policy(self, request: HttpRequest, response: HttpResponseBase) -> str:
diff --git a/docs/nonce.rst b/docs/nonce.rst
index 0cf07ab..95cea28 100644
--- a/docs/nonce.rst
+++ b/docs/nonce.rst
@@ -42,8 +42,19 @@ above script being allowed.
 
 .. Note::
 
-   The nonce will only be added to the CSP headers if it is used.
+   The nonce will only be included in the CSP header if:
 
+     - ``csp.constants.NONCE`` is present in the ``script-src`` or ``style-src`` directives, **and**
+     - ``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.
+
+   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.
 
 ``Context Processor``
 =====================


Test patch:
diff --git a/csp/tests/test_context_processors.py b/csp/tests/test_context_processors.py
index 164e35a..8f98a00 100644
--- a/csp/tests/test_context_processors.py
+++ b/csp/tests/test_context_processors.py
@@ -1,7 +1,10 @@
 from django.http import HttpResponse
 from django.test import RequestFactory
 
+import pytest
+
 from csp.context_processors import nonce
+from csp.exceptions import CSPNonceError
 from csp.middleware import CSPMiddleware
 from csp.tests.utils import response
 
@@ -15,9 +18,25 @@ def test_nonce_context_processor() -> None:
     context = nonce(request)
 
     response = HttpResponse()
+    csp_nonce = getattr(request, "csp_nonce")
     mw.process_response(request, response)
 
-    assert context["CSP_NONCE"] == getattr(request, "csp_nonce")
+    assert context["CSP_NONCE"] == csp_nonce
+
+
+def test_nonce_context_processor_after_response() -> None:
+    request = rf.get("/")
+    mw.process_request(request)
+    context = nonce(request)
+
+    response = HttpResponse()
+    csp_nonce = getattr(request, "csp_nonce")
+    mw.process_response(request, response)
+
+    assert context["CSP_NONCE"] == csp_nonce
+
+    with pytest.raises(CSPNonceError):
+        str(getattr(request, "csp_nonce"))
 
 
 def test_nonce_context_processor_with_middleware_disabled() -> None:
diff --git a/csp/tests/test_decorators.py b/csp/tests/test_decorators.py
index 7e29b69..bac1a51 100644
--- a/csp/tests/test_decorators.py
+++ b/csp/tests/test_decorators.py
@@ -59,11 +59,11 @@ def test_csp_update() -> None:
     response = view_with_decorator(request)
     assert getattr(response, "_csp_update") == {"img-src": ["bar.com", NONCE]}
     mw.process_request(request)
-    assert getattr(request, "csp_nonce")  # Here to trigger the nonce creation.
+    csp_nonce = str(getattr(request, "csp_nonce"))  # This also triggers the nonce creation.
     mw.process_response(request, response)
     assert HEADER_REPORT_ONLY not in response.headers
     policy_list = sorted(response[HEADER].split("; "))
-    assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{getattr(request, 'csp_nonce')}'"]
+    assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{csp_nonce}'"]
 
     response = view_without_decorator(request)
     mw.process_response(request, response)
@@ -92,11 +92,11 @@ def test_csp_update_ro() -> None:
     response = view_with_decorator(request)
     assert getattr(response, "_csp_update_ro") == {"img-src": ["bar.com", NONCE]}
     mw.process_request(request)
-    assert getattr(request, "csp_nonce")  # Here to trigger the nonce creation.
+    csp_nonce = str(getattr(request, "csp_nonce"))  # This also triggers the nonce creation.
     mw.process_response(request, response)
     assert HEADER not in response.headers
     policy_list = sorted(response[HEADER_REPORT_ONLY].split("; "))
-    assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{getattr(request, 'csp_nonce')}'"]
+    assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{csp_nonce}'"]
 
     response = view_without_decorator(request)
     mw.process_response(request, response)
diff --git a/csp/tests/test_middleware.py b/csp/tests/test_middleware.py
index d3011cf..fd26ba7 100644
--- a/csp/tests/test_middleware.py
+++ b/csp/tests/test_middleware.py
@@ -6,7 +6,10 @@ from django.http import (
 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
 from csp.tests.utils import response
 
@@ -155,3 +158,12 @@ def test_nonce_regenerated_on_new_request() -> None:
     mw.process_response(request2, response2)
     assert nonce1 not in response2[HEADER]
     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.
+    request = rf.get("/")
+    mw.process_request(request)
+    mw.process_response(request, HttpResponse())
+    with pytest.raises(CSPNonceError):
+        str(getattr(request, "csp_nonce"))