From 1480ec3d18cf9e90ddb92241c11300d4e59db9d6 Mon Sep 17 00:00:00 2001 From: spatel033 <177532680+spatel033@users.noreply.github.com> Date: Wed, 4 Dec 2024 17:31:20 +0000 Subject: [PATCH] Refactor csp_nonce --- app/__init__.py | 11 +++++++++- app/templates/admin_template.html | 4 +++- .../components/webauthn-api-check.html | 2 +- app/templates/unbranded_template.html | 2 ++ .../views/email-link-interstitial.html | 2 +- tests/app/main/views/test_headers.py | 11 ++++++++-- tests/conftest.py | 21 ++++++++++++++++++- 7 files changed, 46 insertions(+), 7 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 498dada8b8..d02c724335 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,6 @@ import os import pathlib +import secrets from collections.abc import Callable from time import monotonic @@ -219,6 +220,7 @@ def init_app(application): application.after_request(useful_headers_after_request) # Load user first (as we want user_id to be available for all calls to API, which service+organisation might make. + application.before_request(make_nonce_before_request) application.before_request(load_user_id_before_request) application.before_request(load_service_before_request) application.before_request(load_organisation_before_request) @@ -336,6 +338,12 @@ def load_user_id_before_request(): g.user_id = get_user_id_from_flask_login_session() +def make_nonce_before_request(): + # `govuk_frontend_jinja/template.html` can be extended and inline ` - + {% endblock %} diff --git a/app/templates/components/webauthn-api-check.html b/app/templates/components/webauthn-api-check.html index 19bc56bc5e..170d568e83 100644 --- a/app/templates/components/webauthn-api-check.html +++ b/app/templates/components/webauthn-api-check.html @@ -1,5 +1,5 @@ {% macro webauthn_api_check() %} - diff --git a/tests/app/main/views/test_headers.py b/tests/app/main/views/test_headers.py index 1bebd37dce..9d55e0e9dc 100644 --- a/tests/app/main/views/test_headers.py +++ b/tests/app/main/views/test_headers.py @@ -1,9 +1,13 @@ def test_owasp_useful_headers_set( client_request, + mocker, mock_get_service_and_organisation_counts, mock_get_letter_rates, mock_get_sms_rate, + fake_nonce, ): + mocker.patch("secrets.token_urlsafe", return_value=fake_nonce) + client_request.logout() response = client_request.get_response(".index") @@ -11,7 +15,7 @@ def test_owasp_useful_headers_set( assert response.headers["X-XSS-Protection"] == "1; mode=block" assert response.headers["Content-Security-Policy"] == ( "default-src 'self' static.example.com 'unsafe-inline';" - "script-src 'self' static.example.com 'unsafe-inline' 'unsafe-eval' data:;" + "script-src 'self' static.example.com 'nonce-TESTs5Vr8v3jgRYLoQuVwA';" "connect-src 'self';" "object-src 'self';" "font-src 'self' static.example.com data:;" @@ -43,7 +47,10 @@ def test_headers_non_ascii_characters_are_replaced( mock_get_service_and_organisation_counts, mock_get_letter_rates, mock_get_sms_rate, + fake_nonce, ): + mocker.patch("secrets.token_urlsafe", return_value=fake_nonce) + client_request.logout() mocker.patch.dict( "app.current_app.config", @@ -54,7 +61,7 @@ def test_headers_non_ascii_characters_are_replaced( assert response.headers["Content-Security-Policy"] == ( "default-src 'self' static.example.com 'unsafe-inline';" - "script-src 'self' static.example.com 'unsafe-inline' 'unsafe-eval' data:;" + "script-src 'self' static.example.com 'nonce-TESTs5Vr8v3jgRYLoQuVwA';" "connect-src 'self';" "object-src 'self';" "font-src 'self' static.example.com data:;" diff --git a/tests/conftest.py b/tests/conftest.py index 14894cb725..7414738125 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2899,7 +2899,7 @@ def os_environ(): @pytest.fixture # noqa (C901 too complex) -def client_request(request, _logged_in_client, mocker, service_one): # noqa (C901 too complex) +def client_request(request, _logged_in_client, mocker, service_one, fake_nonce): # noqa (C901 too complex) def block_method(object, method_name, preferred_method_name): def blocked_method(*args, **kwargs): raise AttributeError( @@ -2962,6 +2962,8 @@ def get_url( ): from flask.templating import _render + mocker.patch("secrets.token_urlsafe", return_value=fake_nonce) + with mock.patch("flask.templating._render", wraps=_render) as mock_render: resp = _logged_in_client.get( url, @@ -3007,6 +3009,8 @@ def get_url( if _test_for_non_smart_quotes: ClientRequest.test_for_non_smart_quotes(page) + ClientRequest.test_for_script_csp_nonce(page) + return page @staticmethod @@ -3149,6 +3153,16 @@ def test_for_non_smart_quotes(page): "'" in el.text or '"' in el.text ), f"Non-smart quote or apostrophe found in <{el.name}>: {normalize_spaces(el.text)}" + @staticmethod + def test_for_script_csp_nonce(page): + for script_tag in page.select("script"): + src = script_tag.get("src") + nonce = script_tag.get("nonce") + if src and "javascripts/all-esm.mjs" in src: + assert nonce is None + else: + assert nonce == fake_nonce + return ClientRequest @@ -4374,3 +4388,8 @@ def mock_get_notifications_count_for_service(mocker): "app.notification_api_client.get_notifications_count_for_service", return_value=100, ) + + +@pytest.fixture(scope="function") +def fake_nonce(): + return "TESTs5Vr8v3jgRYLoQuVwA"