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:
+2
-3
@@ -3,9 +3,8 @@ registration/login.html)."""
|
||||
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.http import HttpResponse
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from common.components import CsrfInput, Div, Element, Input, Node
|
||||
from common.components import CsrfInput, Div, Element, Input, Node, Safe
|
||||
from common.components.primitives import Td, Tr
|
||||
from common.layout import render_page
|
||||
|
||||
@@ -15,7 +14,7 @@ def _login_content(form, request) -> Node:
|
||||
"table",
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
mark_safe(str(form.as_table())),
|
||||
Safe(str(form.as_table())),
|
||||
Tr(
|
||||
children=[
|
||||
Td(),
|
||||
|
||||
+8
-8
@@ -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(" "),
|
||||
Safe(" "),
|
||||
Popover(
|
||||
popover_content="Original release year",
|
||||
wrapped_classes="text-slate-500 text-2xl",
|
||||
|
||||
@@ -22,6 +22,7 @@ from common.components import (
|
||||
NameWithIcon,
|
||||
Node,
|
||||
Popover,
|
||||
Safe,
|
||||
SearchField,
|
||||
SessionDeviceSelector,
|
||||
paginated_table_content,
|
||||
@@ -199,9 +200,9 @@ def _session_fields(form) -> Fragment:
|
||||
"""
|
||||
rows: list[Node] = []
|
||||
for field in form:
|
||||
children: list[SafeText | str] = [
|
||||
mark_safe(str(field.label_tag())),
|
||||
mark_safe(str(field)),
|
||||
children: list[Node | str] = [
|
||||
Safe(str(field.label_tag())),
|
||||
Safe(str(field)),
|
||||
]
|
||||
if field.name in ("timestamp_start", "timestamp_end"):
|
||||
this_side = "start" if field.name == "timestamp_start" else "end"
|
||||
|
||||
@@ -9,9 +9,18 @@ from django.template.defaultfilters import date as date_filter
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.urls import reverse
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from common.components import A, Div, Element, GameLink, Node, Td, Th, Tr, YearPicker
|
||||
from common.components import (
|
||||
A,
|
||||
Div,
|
||||
Element,
|
||||
GameLink,
|
||||
Node,
|
||||
Safe,
|
||||
Td,
|
||||
Th,
|
||||
Tr,
|
||||
YearPicker,
|
||||
)
|
||||
from common.time import durationformat, format_duration
|
||||
|
||||
_CELL = "px-2 sm:px-4 md:px-6 md:py-2"
|
||||
@@ -70,7 +79,7 @@ def _purchase_name(purchase) -> Node:
|
||||
name = game_name or purchase.name
|
||||
link = GameLink(first_game.id, name)
|
||||
suffix = f" ({first_game.name} {purchase.get_type_display()})"
|
||||
return mark_safe(str(link) + conditional_escape(suffix))
|
||||
return Safe(str(link) + conditional_escape(suffix))
|
||||
name = game_name or first_game.name
|
||||
return GameLink(first_game.id, name)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user