Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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