Ban SafeText-as-child: only Safe nodes render unescaped

Tightens the child model so the type is honest end to end. Previously a
``SafeText``/``mark_safe`` string passed as a child rendered unescaped — a
trusted-HTML-as-string backdoor that ``Child = Node | str`` couldn't express
(every ``SafeText`` is a ``str``). Now ``_child_key`` escapes *every* string
child; the only way to put trusted pre-rendered HTML into the tree is a
``Safe`` node. So a ``str`` child is always untrusted text — which is exactly
what the renderer escapes.

Converted the trusted-HTML children that relied on the old passthrough:

- ``CsrfInput`` and the Alpine selectors (``GameStatusSelector`` /
  ``SessionDeviceSelector``) now return ``Safe`` nodes instead of ``mark_safe``
  strings — they are always tree children.
- ``popover_content`` is now a ``Child`` (it is rendered as a child); the one
  HTML caller (``LinkedPurchase``) passes ``Safe(...)``.
- View-side children that were ``mark_safe`` strings → ``Safe(...)``:
  ``_played_row`` (game detail), the stat SVGs and `` `` spacer (game),
  the login table (auth), the manual session-form field/label markup
  (session), and ``_purchase_name`` (stats).
- ``SimpleTable.header_action`` typed ``Child``.

The script-tag string helpers (``ModuleScript`` / ``StaticScript`` /
``ExternalScript``) stay ``SafeText`` strings: they are only ever joined into
the ``scripts=`` string, never used as tree children.

``Children`` regains a bare ``Node`` member (a single node child is valid);
the one ``*children`` site (``Popover``) normalises via ``as_children`` first.
Tests that asserted the old SafeText-passthrough now assert the new rule
(mark_safe child escaped; ``Safe`` node passes through). Full suite green
(445; +2 new escaping tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 18:35:43 +02:00
parent 544da26a9d
commit 0c6c536d07
10 changed files with 86 additions and 54 deletions
+8 -8
View File
@@ -28,9 +28,11 @@ from common.components import (
Modal,
ModuleScript,
NameWithIcon,
Node,
Popover,
PopoverTruncated,
PurchasePrice,
Safe,
SearchField,
SimpleTable,
Ul,
@@ -386,7 +388,7 @@ _PLAYED_ROW_TEMPLATE = """<div class="flex gap-2 items-center" x-data="{ open: f
</div>"""
def _played_row(game: Game, request: HttpRequest) -> SafeText:
def _played_row(game: Game, request: HttpRequest) -> Node:
"""The 'Played N times' control with its Alpine.js dropdown."""
replacements = {
"@@PLAYED_COUNT@@": str(game.playevents.count()),
@@ -400,7 +402,7 @@ def _played_row(game: Game, request: HttpRequest) -> SafeText:
html = _PLAYED_ROW_TEMPLATE
for token, value in replacements.items():
html = html.replace(token, value)
return mark_safe(html)
return Safe(html)
def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
@@ -408,14 +410,12 @@ def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> Sa
popover_content=tooltip,
wrapped_classes="flex gap-2 items-center",
id=popover_id,
children=[mark_safe(_STAT_SVGS[svg_key]), str(value)],
children=[Safe(_STAT_SVGS[svg_key]), str(value)],
)
def _meta_row(
label: str, value: SafeText | str, extra: SafeText | str = ""
) -> SafeText:
children: list[SafeText | str] = [
def _meta_row(label: str, value: Node | str, extra: Node | str = "") -> Node:
children: list[Node | str] = [
Span(attributes=[("class", "uppercase")], children=[label]),
value,
]
@@ -565,7 +565,7 @@ def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> S
]
+ (
[
mark_safe("&nbsp;"),
Safe("&nbsp;"),
Popover(
popover_content="Original release year",
wrapped_classes="text-slate-500 text-2xl",