Skip to content

Commit d86c93a

Browse files
robhudsonjwhitlock
authored andcommitted
Fix #247: Raise error when nonce accessed after response
1 parent e6ae74e commit d86c93a

File tree

6 files changed

+62
-6
lines changed

6 files changed

+62
-6
lines changed

csp/exceptions.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class CSPNonceError(Exception):
2+
pass

csp/middleware.py

+12
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from django.utils.functional import SimpleLazyObject
1414

1515
from csp.constants import HEADER, HEADER_REPORT_ONLY
16+
from csp.exceptions import CSPNonceError
1617
from csp.utils import DIRECTIVES_T, build_policy
1718

1819
if TYPE_CHECKING:
@@ -48,6 +49,12 @@ def _make_nonce(self, request: HttpRequest) -> str:
4849
setattr(request, "_csp_nonce", nonce)
4950
return nonce
5051

52+
@staticmethod
53+
def _csp_nonce_post_response() -> None:
54+
raise CSPNonceError(
55+
"The 'csp_nonce' attribute is not available after the CSP header has been written. Consider adjusting your MIDDLEWARE order."
56+
)
57+
5158
def process_request(self, request: HttpRequest) -> None:
5259
nonce = partial(self._make_nonce, request)
5360
setattr(request, "csp_nonce", SimpleLazyObject(nonce))
@@ -85,6 +92,11 @@ def process_response(self, request: HttpRequest, response: HttpResponseBase) ->
8592
if no_header and is_not_exempt and is_not_excluded:
8693
response[HEADER_REPORT_ONLY] = csp_ro
8794

95+
# Once we've written the header, accessing the `request.csp_nonce` will no longer trigger
96+
# the nonce to be added to the header. Instead we throw an error here to catch this since
97+
# this has security implications.
98+
setattr(request, "csp_nonce", SimpleLazyObject(self._csp_nonce_post_response))
99+
88100
return response
89101

90102
def build_policy(self, request: HttpRequest, response: HttpResponseBase) -> str:

csp/tests/test_context_processors.py

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from django.http import HttpResponse
22
from django.test import RequestFactory
33

4+
import pytest
5+
46
from csp.context_processors import nonce
7+
from csp.exceptions import CSPNonceError
58
from csp.middleware import CSPMiddleware
69
from csp.tests.utils import response
710

@@ -15,9 +18,25 @@ def test_nonce_context_processor() -> None:
1518
context = nonce(request)
1619

1720
response = HttpResponse()
21+
csp_nonce = getattr(request, "csp_nonce")
1822
mw.process_response(request, response)
1923

20-
assert context["CSP_NONCE"] == getattr(request, "csp_nonce")
24+
assert context["CSP_NONCE"] == csp_nonce
25+
26+
27+
def test_nonce_context_processor_after_response() -> None:
28+
request = rf.get("/")
29+
mw.process_request(request)
30+
context = nonce(request)
31+
32+
response = HttpResponse()
33+
csp_nonce = getattr(request, "csp_nonce")
34+
mw.process_response(request, response)
35+
36+
assert context["CSP_NONCE"] == csp_nonce
37+
38+
with pytest.raises(CSPNonceError):
39+
str(getattr(request, "csp_nonce"))
2140

2241

2342
def test_nonce_context_processor_with_middleware_disabled() -> None:

csp/tests/test_decorators.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,11 @@ def view_with_decorator(request: HttpRequest) -> HttpResponseBase:
5959
response = view_with_decorator(request)
6060
assert getattr(response, "_csp_update") == {"img-src": ["bar.com", NONCE]}
6161
mw.process_request(request)
62-
assert getattr(request, "csp_nonce") # Here to trigger the nonce creation.
62+
csp_nonce = str(getattr(request, "csp_nonce")) # This also triggers the nonce creation.
6363
mw.process_response(request, response)
6464
assert HEADER_REPORT_ONLY not in response.headers
6565
policy_list = sorted(response[HEADER].split("; "))
66-
assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{getattr(request, 'csp_nonce')}'"]
66+
assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{csp_nonce}'"]
6767

6868
response = view_without_decorator(request)
6969
mw.process_response(request, response)
@@ -92,11 +92,11 @@ def view_with_decorator(request: HttpRequest) -> HttpResponseBase:
9292
response = view_with_decorator(request)
9393
assert getattr(response, "_csp_update_ro") == {"img-src": ["bar.com", NONCE]}
9494
mw.process_request(request)
95-
assert getattr(request, "csp_nonce") # Here to trigger the nonce creation.
95+
csp_nonce = str(getattr(request, "csp_nonce")) # This also triggers the nonce creation.
9696
mw.process_response(request, response)
9797
assert HEADER not in response.headers
9898
policy_list = sorted(response[HEADER_REPORT_ONLY].split("; "))
99-
assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{getattr(request, 'csp_nonce')}'"]
99+
assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{csp_nonce}'"]
100100

101101
response = view_without_decorator(request)
102102
mw.process_response(request, response)

csp/tests/test_middleware.py

+12
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
from django.test import RequestFactory
77
from django.test.utils import override_settings
88

9+
import pytest
10+
911
from csp.constants import HEADER, HEADER_REPORT_ONLY, SELF
12+
from csp.exceptions import CSPNonceError
1013
from csp.middleware import CSPMiddleware
1114
from csp.tests.utils import response
1215

@@ -155,3 +158,12 @@ def test_nonce_regenerated_on_new_request() -> None:
155158
mw.process_response(request2, response2)
156159
assert nonce1 not in response2[HEADER]
157160
assert nonce2 not in response1[HEADER]
161+
162+
163+
def test_nonce_attribute_error() -> None:
164+
# Test `CSPNonceError` is raised when accessing the nonce after the response has been processed.
165+
request = rf.get("/")
166+
mw.process_request(request)
167+
mw.process_response(request, HttpResponse())
168+
with pytest.raises(CSPNonceError):
169+
str(getattr(request, "csp_nonce"))

docs/nonce.rst

+12-1
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,19 @@ above script being allowed.
4242

4343
.. Note::
4444

45-
The nonce will only be added to the CSP headers if it is used.
45+
The nonce will only be included in the CSP header if:
4646

47+
- ``csp.constants.NONCE`` is present in the ``script-src`` or ``style-src`` directives, **and**
48+
- ``request.csp_nonce`` is accessed during the request lifecycle, after the middleware
49+
processes the request but before it processes the response.
50+
51+
If ``request.csp_nonce`` is accessed **after** the response has been processed by the middleware,
52+
a ``csp.exceptions.CSPNonceError`` will be raised.
53+
54+
Middleware that accesses ``request.csp_nonce`` **must be placed after**
55+
``csp.middleware.CSPMiddleware`` in the ``MIDDLEWARE`` setting. This ensures that
56+
``CSPMiddleware`` properly processes the response and includes the nonce in the CSP header before
57+
other middleware attempts to use it.
4758

4859
``Context Processor``
4960
=====================

0 commit comments

Comments
 (0)