Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions result_server/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
abort,
current_app,
flash,
make_response,
redirect,
render_template,
request,
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"]
Expand Down
23 changes: 15 additions & 8 deletions result_server/templates/_pagination.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<!-- templates/_pagination.html -->
{% 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 %}

<div class="control-row pagination-row">
<span class="pagination-summary">Showing {{ pagination.total }} results</span>
Expand All @@ -27,9 +27,16 @@
{% endif %}
</span>

<select onchange="window.location.href='?page=1&per_page='+this.value+'{{ base_params }}'" class="pagination-per-page">
{% for size in [50, 100, 200] %}
<option value="{{ size }}" {% if current_per_page == size %}selected{% endif %}>{{ size }} / page</option>
{% endfor %}
</select>
<form method="get" class="pagination-per-page-form">
<input type="hidden" name="page" value="1">
{% if current_system %}<input type="hidden" name="system" value="{{ current_system }}">{% endif %}
{% if current_code %}<input type="hidden" name="code" value="{{ current_code }}">{% endif %}
{% if current_exp %}<input type="hidden" name="exp" value="{{ current_exp }}">{% endif %}
<select name="per_page" class="pagination-per-page">
{% for size in [50, 100, 200] %}
<option value="{{ size }}" {% if current_per_page == size %}selected{% endif %}>{{ size }} / page</option>
{% endfor %}
</select>
<button type="submit" class="soft-button pagination-link">Apply</button>
</form>
</div>
6 changes: 6 additions & 0 deletions result_server/templates/_table_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
66 changes: 66 additions & 0 deletions result_server/tests/test_auth_routes.py
Original file line number Diff line number Diff line change
@@ -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)
25 changes: 25 additions & 0 deletions result_server/tests/test_portal_list_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="<CASE0>",
)

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"),
Expand Down
Loading