diff --git a/result_server/routes/auth.py b/result_server/routes/auth.py index 09caa20..724ef48 100644 --- a/result_server/routes/auth.py +++ b/result_server/routes/auth.py @@ -7,6 +7,7 @@ abort, current_app, flash, + make_response, redirect, render_template, request, @@ -29,6 +30,16 @@ auth_bp = Blueprint("auth", __name__, url_prefix="/auth") +def _add_no_store_headers(response): + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + return response + + +def _render_no_store_template(template_name, **context): + return _add_no_store_headers(make_response(render_template(template_name, **context))) + + def _redis_ping_ok(redis_conn): """Return whether the configured Redis connection is currently usable.""" if not redis_conn: @@ -94,7 +105,7 @@ def _render_login_totp_step(email): def _render_setup_page(email, token, secret): issuer = current_app.config.get("TOTP_ISSUER", "CX Portal") qr_data = generate_qr_base64(secret, email, issuer=issuer) - return render_template( + return _render_no_store_template( "auth_setup.html", error=False, qr_data=qr_data, @@ -201,7 +212,7 @@ def setup(token): if not invitation: flash("This invitation link is invalid or has expired.") - return render_template("auth_setup.html", error=True) + return _render_no_store_template("auth_setup.html", error=True) email = invitation["email"] affiliations = invitation["affiliations"] diff --git a/result_server/templates/_pagination.html b/result_server/templates/_pagination.html index 12aa4ee..700c4ff 100644 --- a/result_server/templates/_pagination.html +++ b/result_server/templates/_pagination.html @@ -1,8 +1,8 @@ {% set base_params = "" %} -{% if current_system %}{% set base_params = base_params ~ "&system=" ~ current_system %}{% endif %} -{% if current_code %}{% set base_params = base_params ~ "&code=" ~ current_code %}{% endif %} -{% if current_exp %}{% set base_params = base_params ~ "&exp=" ~ current_exp %}{% endif %} +{% if current_system %}{% set base_params = base_params ~ "&system=" ~ (current_system | urlencode) %}{% endif %} +{% if current_code %}{% set base_params = base_params ~ "&code=" ~ (current_code | urlencode) %}{% endif %} +{% if current_exp %}{% set base_params = base_params ~ "&exp=" ~ (current_exp | urlencode) %}{% endif %}
Showing {{ pagination.total }} results @@ -27,9 +27,16 @@ {% endif %} - +
+ + {% if current_system %}{% endif %} + {% if current_code %}{% endif %} + {% if current_exp %}{% endif %} + + +
diff --git a/result_server/templates/_table_base.html b/result_server/templates/_table_base.html index 302ee80..b302179 100644 --- a/result_server/templates/_table_base.html +++ b/result_server/templates/_table_base.html @@ -180,6 +180,12 @@ .pagination-disabled { color: #aaa; } + .pagination-per-page-form { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + } .pagination-per-page { padding: 4px; font-size: 14px; diff --git a/result_server/tests/test_auth_routes.py b/result_server/tests/test_auth_routes.py new file mode 100644 index 0000000..91c621f --- /dev/null +++ b/result_server/tests/test_auth_routes.py @@ -0,0 +1,66 @@ +"""Tests for authentication route security headers.""" + +import os +import shutil +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from test_support import build_portal_route_app, install_portal_test_stubs + +install_portal_test_stubs() + + +class _SetupStore: + def get_invitation(self, token): + if token != "token-1": + return None + return {"email": "user@example.com", "affiliations": ["dev"]} + + +def _portal_app(): + received = tempfile.mkdtemp() + estimated = tempfile.mkdtemp() + app = build_portal_route_app( + templates_dir=os.path.join(os.path.dirname(__file__), "..", "templates"), + received_dir=received, + estimated_dir=estimated, + user_store=_SetupStore(), + include_admin=False, + ) + return app, (received, estimated) + + +def _cleanup(paths): + for path in paths: + shutil.rmtree(path) + + +def test_setup_page_sets_no_store_headers(monkeypatch): + app, temp_dirs = _portal_app() + try: + from routes import auth as auth_routes + + monkeypatch.setattr(auth_routes, "generate_qr_base64", lambda secret, email, issuer: "qr") + with app.test_client() as client: + resp = client.get("/auth/setup/token-1") + + assert resp.status_code == 200 + assert "no-store" in resp.headers.get("Cache-Control", "") + assert resp.headers.get("Pragma") == "no-cache" + finally: + _cleanup(temp_dirs) + + +def test_invalid_setup_link_sets_no_store_headers(): + app, temp_dirs = _portal_app() + try: + with app.test_client() as client: + resp = client.get("/auth/setup/bad-token") + + assert resp.status_code == 200 + assert "no-store" in resp.headers.get("Cache-Control", "") + assert resp.headers.get("Pragma") == "no-cache" + finally: + _cleanup(temp_dirs) diff --git a/result_server/tests/test_portal_list_templates.py b/result_server/tests/test_portal_list_templates.py index bb4a878..0b28924 100644 --- a/result_server/tests/test_portal_list_templates.py +++ b/result_server/tests/test_portal_list_templates.py @@ -148,6 +148,31 @@ def test_results_template_renders_ncu_options_tooltip(): assert "ncu_report" in html +def test_pagination_template_urlencodes_filters_without_inline_javascript(): + app = build_portal_shell_app( + templates_dir=os.path.join(os.path.dirname(__file__), "..", "templates"), + ) + with app.test_request_context("/results"): + from flask import render_template + + html = render_template( + "_pagination.html", + pagination={"total": 120, "page": 1, "total_pages": 3}, + current_per_page=50, + current_system="Sys');alert(1)//", + current_code='code" onclick="alert(1)', + current_exp="", + ) + + assert "onchange=" not in html + assert "window.location.href" not in html + assert "system=Sys%27%29%3Balert%281%29" in html + assert "code=code%22%20onclick%3D%22alert%281%29" in html + assert "exp=%3CCASE0%3E" in html + assert "Sys');alert(1)//" not in html + assert 'code" onclick="alert(1)' not in html + + def test_estimated_results_template_renders_table_note(): app = build_portal_shell_app( templates_dir=os.path.join(os.path.dirname(__file__), "..", "templates"),