diff --git a/app/__init__.py b/app/__init__.py index 339362561..05ac4c0e7 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -301,15 +301,44 @@ def init_app(app): g.start = monotonic() g.endpoint = request.endpoint + @app.before_request + def handle_options(): + if request.method == "OPTIONS": + response = make_response("", 204) + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PUT, DELETE, OPTIONS" + ) + response.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, Authorization" + ) + response.headers["Access-Control-Max-Age"] = "3600" + return response + @app.after_request def after_request(response): + # Security headers for government compliance response.headers.add("X-Content-Type-Options", "nosniff") + response.headers.add("X-Frame-Options", "DENY") + response.headers.add("X-XSS-Protection", "1; mode=block") + response.headers.add("Referrer-Policy", "strict-origin-when-cross-origin") + response.headers.add( + "Permissions-Policy", "geolocation=(), microphone=(), camera=()" + ) - # Some dynamic scan findings + # CORS-related security headers response.headers.add("Cross-Origin-Opener-Policy", "same-origin") response.headers.add("Cross-Origin-Embedder-Policy", "require-corp") response.headers.add("Cross-Origin-Resource-Policy", "same-origin") - response.headers.add("Cross-Origin-Opener-Policy", "same-origin") + + if not request.path.startswith("/docs"): + response.headers.add( + "Content-Security-Policy", "default-src 'none'; frame-ancestors 'none';" + ) + + response.headers.add( + "Strict-Transport-Security", "max-age=31536000; includeSubDomains" + ) return response diff --git a/app/status/healthcheck.py b/app/status/healthcheck.py index 012be880c..648c4225f 100644 --- a/app/status/healthcheck.py +++ b/app/status/healthcheck.py @@ -14,17 +14,20 @@ status = Blueprint("status", __name__) def show_status(): try: if request.args.get("simple", None): - return jsonify(status="ok"), 200 + response = jsonify(status="ok") else: - return ( - jsonify( - status="ok", # This should be considered part of the public API - git_commit=version.__git_commit__, - build_time=version.__time__, - db_version=get_db_version(), - ), - 200, + response = jsonify( + status="ok", # This should be considered part of the public API + git_commit=version.__git_commit__, + build_time=version.__time__, + db_version=get_db_version(), ) + + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + + return response, 200 except Exception as e: current_app.logger.error( f"Unexpected error in show_status: {str(e)}", exc_info=True @@ -36,13 +39,16 @@ def show_status(): @status.route("/_status/live-service-and-organization-counts") def live_service_and_organization_counts(): try: - return ( - jsonify( - organizations=dao_count_organizations_with_live_services(), - services=dao_count_live_services(), - ), - 200, + response = jsonify( + organizations=dao_count_organizations_with_live_services(), + services=dao_count_live_services(), ) + + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + + return response, 200 except Exception as e: current_app.logger.error( f"Unexpected error in live_service_and_organization_counts: {str(e)}", diff --git a/notifications_python_client/base.py b/notifications_python_client/base.py index 9e1f7615a..ba6038fa6 100644 --- a/notifications_python_client/base.py +++ b/notifications_python_client/base.py @@ -23,9 +23,7 @@ class BaseAPIClient: This class is not thread-safe. """ - def __init__( - self, api_key, base_url=API_HOST_NAME, timeout=30 - ): + def __init__(self, api_key, base_url=API_HOST_NAME, timeout=30): """ Initialise the client Error if either of base_url or secret missing diff --git a/tests/app/test_security_headers.py b/tests/app/test_security_headers.py new file mode 100644 index 000000000..c96ce9fac --- /dev/null +++ b/tests/app/test_security_headers.py @@ -0,0 +1,30 @@ +import pytest + + +@pytest.mark.usefixtures('notify_db_session') +class TestSecurityHeaders: + """Test security headers for ZAP scan compliance.""" + + def test_options_request_returns_204_with_cors_headers(self, client): + """Test that OPTIONS requests return 204 with proper CORS headers.""" + response = client.options('/') + + assert response.status_code == 204 + assert response.headers.get('Access-Control-Allow-Origin') == '*' + assert response.headers.get('Access-Control-Allow-Methods') == 'GET, POST, PUT, DELETE, OPTIONS' + assert response.headers.get('Access-Control-Allow-Headers') == 'Content-Type, Authorization' + assert response.headers.get('Access-Control-Max-Age') == '3600' + + @pytest.mark.parametrize("endpoint", [ + '/_status', + '/_status?simple=1', + '/_status/live-service-and-organization-counts' + ]) + def test_status_endpoints_have_cache_control_headers(self, client, endpoint): + """Test that all status endpoints have proper cache-control headers.""" + response = client.get(endpoint) + + assert response.status_code == 200 + assert response.headers.get('Cache-Control') == 'no-cache, no-store, must-revalidate' + assert response.headers.get('Pragma') == 'no-cache' + assert response.headers.get('Expires') == '0'