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 ` 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..17c7cfe311 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ import html5lib import pytest -from flask import Flask, url_for +from flask import Flask, current_app, url_for from notifications_python_client.errors import HTTPError from notifications_utils.url_safe_token import generate_token @@ -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( @@ -2934,6 +2934,7 @@ def get( _test_for_elements_without_class=True, _test_forms_have_an_action_set=True, _test_for_non_smart_quotes=True, + _test_for_script_csp_nonce=True, _optional_args="", **endpoint_kwargs, ): @@ -2946,8 +2947,10 @@ def get( _test_for_elements_without_class=_test_for_elements_without_class, _test_forms_have_an_action_set=_test_forms_have_an_action_set, _test_for_non_smart_quotes=_test_for_non_smart_quotes, + _test_for_script_csp_nonce=_test_for_script_csp_nonce, ) + # ruff: noqa: C901 @staticmethod def get_url( url, @@ -2958,10 +2961,13 @@ def get_url( _test_for_elements_without_class=True, _test_forms_have_an_action_set=True, _test_for_non_smart_quotes=True, + _test_for_script_csp_nonce=True, **endpoint_kwargs, ): 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 +3013,9 @@ def get_url( if _test_for_non_smart_quotes: ClientRequest.test_for_non_smart_quotes(page) + if _test_for_script_csp_nonce: + ClientRequest.test_for_script_csp_nonce(page) + return page @staticmethod @@ -3149,6 +3158,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 current_app.config["ASSET_DOMAIN"] in src: + assert nonce is None + else: + assert nonce == fake_nonce + return ClientRequest @@ -4374,3 +4393,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"