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"