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 %}
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"),