diff --git a/README.md b/README.md index 59814fd..320e58a 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,7 @@ Default settings: ```python NEXTJS_SETTINGS = { "nextjs_server_url": "http://127.0.0.1:3000", + "ensure_csrf_token": True, } ``` @@ -274,6 +275,13 @@ Default settings: The URL of Next.js server (started by `npm run dev` or `npm run start`) +### `ensure_csrf_token` + +If user does not have a CSRF token, ensure that one is generated and included in the initial request to the NextJS +server, by calling Django's `django.middleware.csrf.get_token`. If `django.middleware.csrf.CsrfViewMiddleware` is +installed, the initial response will include a `Set-Cookie` header to persist the CSRF token value on the client. +This behaviour is enabled by default. + ## Development - Install development dependencies in your virtualenv with `pip install -e '.[dev]'` diff --git a/django_nextjs/app_settings.py b/django_nextjs/app_settings.py index 8490eab..c641030 100644 --- a/django_nextjs/app_settings.py +++ b/django_nextjs/app_settings.py @@ -5,3 +5,5 @@ NEXTJS_SETTINGS = getattr(settings, "NEXTJS_SETTINGS", {}) NEXTJS_SERVER_URL = NEXTJS_SETTINGS.get("nextjs_server_url", "http://127.0.0.1:3000") + +ENSURE_CSRF_TOKEN = NEXTJS_SETTINGS.get("ensure_csrf_token", True) diff --git a/django_nextjs/render.py b/django_nextjs/render.py index 1738f80..028fd42 100644 --- a/django_nextjs/render.py +++ b/django_nextjs/render.py @@ -10,7 +10,7 @@ from django.template.loader import render_to_string from multidict import MultiMapping -from .app_settings import NEXTJS_SERVER_URL +from .app_settings import ENSURE_CSRF_TOKEN, NEXTJS_SERVER_URL from .utils import filter_mapping_obj morsel = Morsel() @@ -49,7 +49,9 @@ def _get_nextjs_request_cookies(request: HttpRequest): https://docs.djangoproject.com/en/3.2/ref/csrf/#is-posting-an-arbitrary-csrf-token-pair-cookie-and-post-data-a-vulnerability """ unreserved_cookies = {k: v for k, v in request.COOKIES.items() if k and not morsel.isReservedKey(k)} - return {**unreserved_cookies, settings.CSRF_COOKIE_NAME: get_csrf_token(request)} + if ENSURE_CSRF_TOKEN is True and settings.CSRF_COOKIE_NAME not in unreserved_cookies: + unreserved_cookies[settings.CSRF_COOKIE_NAME] = get_csrf_token(request) + return unreserved_cookies def _get_nextjs_request_headers(request: HttpRequest, headers: Union[Dict, None] = None): diff --git a/tests/test_render.py b/tests/test_render.py index e29d77f..cc91ebd 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -83,6 +83,55 @@ async def test_nextjs_page(rf: RequestFactory): assert kwargs["headers"]["extra"] == "headers" +@pytest.mark.asyncio +async def test_set_csrftoken(rf: RequestFactory): + def get_mock_request(): + return rf.get("/random/path") + + async def get_mock_response(request: RequestFactory): + with patch("aiohttp.ClientSession") as mock_session: + with patch("aiohttp.ClientSession.get") as mock_get: + mock_get.return_value.__aenter__.return_value.text = AsyncMock(return_value="") + mock_get.return_value.__aenter__.return_value.status = 200 + mock_session.return_value.__aenter__ = AsyncMock(return_value=MagicMock(get=mock_get)) + return await nextjs_page(allow_redirects=True)(request), mock_session + + # User does not have csrftoken and django-nextjs is not configured to guarantee one + with patch("django_nextjs.render.ENSURE_CSRF_TOKEN", False): + http_request = get_mock_request() + _, mock_session = await get_mock_response(http_request) + args, kwargs = mock_session.call_args + # This triggers CsrfViewMiddleware to call response.set_cookie with updated csrftoken value + assert "CSRF_COOKIE_NEEDS_UPDATE" not in http_request.META + assert "csrftoken" not in kwargs["cookies"] + + # User does not have csrftoken and django-nextjs is configured to guarantee one + with patch("django_nextjs.render.ENSURE_CSRF_TOKEN", True): + http_request = get_mock_request() + _, mock_session = await get_mock_response(http_request) + args, kwargs = mock_session.call_args + assert "CSRF_COOKIE_NEEDS_UPDATE" in http_request.META + assert "csrftoken" in kwargs["cookies"] + + # User has csrftoken and django-nextjs is not configured to guarantee one + with patch("django_nextjs.render.ENSURE_CSRF_TOKEN", False): + http_request = get_mock_request() + http_request.COOKIES["csrftoken"] = "whatever" + _, mock_session = await get_mock_response(http_request) + args, kwargs = mock_session.call_args + assert "CSRF_COOKIE_NEEDS_UPDATE" not in http_request.META + assert "csrftoken" in kwargs["cookies"] + + # User has csrftoken and django-nextjs is configured to guarantee one + with patch("django_nextjs.render.ENSURE_CSRF_TOKEN", True): + http_request = get_mock_request() + http_request.COOKIES["csrftoken"] = "whatever" + _, mock_session = await get_mock_response(http_request) + args, kwargs = mock_session.call_args + assert "CSRF_COOKIE_NEEDS_UPDATE" not in http_request.META + assert "csrftoken" in kwargs["cookies"] + + @pytest.mark.asyncio async def test_render_nextjs_page_to_string(rf: RequestFactory): request = rf.get(f"/random/path")