fix(web): render <strong> in /me/activity hero subtitle instead of escaping it (#312)

The subtitle was built by ~-concatenating a Markup operand
(user.email | e) with HTML string literals. Under autoescaping,
Jinja2's markup_join escapes every non-Markup part once it hits a
Markup operand — so the literal <strong> tags became &lt;strong&gt;
and the page showed literal "<strong>...</strong>" text around the
email. The | safe in _page_hero.html was too late to undo it.

Switch to {% set %}...{% endset %} block capture: the literal
<strong> stays HTML while {{ user.email }} is still autoescaped.
Regression test asserts the tags render and a hostile email stays
escaped.
This commit is contained in:
ZdenekSrotyr 2026-05-14 22:27:34 +02:00 committed by GitHub
parent a1c7849b3e
commit 8b5b0f8ef5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 26 additions and 1 deletions

View file

@ -10,6 +10,15 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased]
### Fixed
- `/me/activity` hero subtitle showed literal `<strong>…</strong>` tags
around the user's email instead of rendering them bold. The subtitle
was built by `~`-concatenating a `Markup` operand (`user.email | e`)
with HTML string literals, which made Jinja2's `markup_join` escape
the literal tags too. Switched to `{% set %}…{% endset %}` block
capture so the literal `<strong>` stays HTML while the email is still
autoescaped.
### Internal
- CI test suite sharded for speed. The `test` job in `.github/workflows/ci.yml` is now a `test-shard` matrix — 4 parallel jobs via `pytest-split`, balanced by a committed `.test_durations` file — aggregated into a single `test` status check so branch protection needs no change. The duplicate full-suite `test` job in `release.yml` is removed (it re-ran the same ~10 min suite a second time on every push to main/feature branches); `release.yml` is now image-build only, with the advisory ruff/mypy steps moved to a lean `lint` job in `ci.yml`. Net: ~10 min → ~3 min wall-clock per push, and the suite runs once instead of twice. Adds `pytest-split` to the `dev` extra.

View file

@ -81,7 +81,10 @@
<div class="activity-page">
{% set page_hero_eyebrow = "Profile" %}
{% set page_hero_title = "My activity" %}
{% set page_hero_subtitle = "Sessions, token usage, data access, and sync activity for <strong>" ~ (user.email | e) ~ "</strong>." %}
{# Block-capture so the literal <strong> stays HTML while {{ user.email }}
is still autoescaped. `~`-concatenating a Markup operand (user.email | e)
made Jinja2's markup_join escape the literal tags too. #}
{% set page_hero_subtitle %}Sessions, token usage, data access, and sync activity for <strong>{{ user.email }}</strong>.{% endset %}
{% include "_page_hero.html" %}
<nav class="tab-strip" role="tablist" aria-label="Activity sections">

View file

@ -422,6 +422,19 @@ class TestAdminRoleGuards:
assert r.status_code == 200
assert b"My activity" in r.content
def test_me_activity_hero_renders_strong_email_unescaped(self, web_client, analyst_cookie):
"""Regression: the /me/activity hero subtitle embeds the user's email
in <strong> tags. Building it via `~` concatenation with a Markup
operand (`user.email | e`) made Jinja2's markup_join escape the
literal tags too, so the page showed literal "<strong>...</strong>"
text. The subtitle must render real <strong> HTML while still
escaping the email itself."""
r = web_client.get("/me/activity", cookies=analyst_cookie, follow_redirects=False)
assert r.status_code == 200
body = r.text
assert "activity for <strong>analyst@test.com</strong>." in body
assert "activity for &lt;strong&gt;" not in body
def test_profile_session_download_returns_file_for_owner(self, web_client, analyst_cookie, tmp_path, monkeypatch):
"""Authenticated owner can fetch their own jsonl with proper Content-Disposition."""
# The seeded analyst is "analyst1" (per conftest.seeded_app).