Skip to content

Commit

Permalink
Fix #247: Raise error when nonce accessed after response
Browse files Browse the repository at this point in the history
  • Loading branch information
robhudson authored and jwhitlock committed Jan 23, 2025
1 parent e6ae74e commit d86c93a
Show file tree
Hide file tree
Showing 6 changed files with 62 additions and 6 deletions.
2 changes: 2 additions & 0 deletions csp/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class CSPNonceError(Exception):
pass
12 changes: 12 additions & 0 deletions csp/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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:
Expand Down Expand Up @@ -48,6 +49,12 @@ def _make_nonce(self, request: HttpRequest) -> str:
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))
Expand Down Expand Up @@ -85,6 +92,11 @@ def process_response(self, request: HttpRequest, response: HttpResponseBase) ->
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:
Expand Down
21 changes: 20 additions & 1 deletion csp/tests/test_context_processors.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions csp/tests/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ def view_with_decorator(request: HttpRequest) -> HttpResponseBase:
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)
Expand Down Expand Up @@ -92,11 +92,11 @@ def view_with_decorator(request: HttpRequest) -> HttpResponseBase:
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)
Expand Down
12 changes: 12 additions & 0 deletions csp/tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
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

Expand Down Expand Up @@ -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"))
13 changes: 12 additions & 1 deletion docs/nonce.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
=====================
Expand Down

0 comments on commit d86c93a

Please sign in to comment.