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
+14 -10
View File
@@ -134,18 +134,20 @@ class Node:
return mark_safe(self._render())
# A renderable child is a node or a string (plain strings are escaped, SafeText
# and nodes pass through). ``Children`` is the type for a builder's ``children``
# A renderable child is a node or a string. Strings are ALWAYS escaped (a string
# is untrusted text — ``SafeText``/``mark_safe`` is escaped too); trusted
# pre-rendered HTML must be a ``Safe`` node. ``Children`` is the type for a
# builder's ``children``
# parameter: a sequence of child nodes/strings, a bare string, or nothing. The
# sequence is a covariant ``Sequence`` so ``list[Element]`` / ``list[Node]`` are
# accepted (a plain ``list[str]`` would be invariant and reject them). A single
# bare ``Node`` is accepted only by ``Element`` itself (which wraps it); the
# higher-level builders take ``Children``.
Child = Node | str
Children = Sequence[Child] | str | None
Children = Sequence[Child] | Node | str | None
def as_children(children: "Children | Node") -> list[Child]:
def as_children(children: Children) -> list[Child]:
"""Normalise a builder's ``children`` argument to a flat list.
Accepts ``None`` (→ empty), a single node/string (→ one-element list), or a
@@ -172,16 +174,18 @@ def as_attributes(attributes: "Attributes | None") -> list[HTMLAttribute]:
def _child_key(child: object) -> tuple[str, bool]:
"""Normalise a child to a ``(text, is_safe)`` pair.
Nodes render to safe HTML; ``SafeText`` (and anything exposing ``__html__``)
is already safe; plain strings are escaped. ``is_safe`` is part of the
render cache key so a safe ``"<b>"`` and an unsafe ``"<b>"`` never collide.
Only :class:`Node` children render unescaped — that includes :class:`Safe`,
the one sanctioned way to put trusted pre-rendered HTML into the tree. Every
*string* child is escaped, ``SafeText``/``mark_safe`` included: a string is
always treated as untrusted text, so trusted markup must be wrapped in
``Safe(...)`` rather than smuggled in as a safe string. ``is_safe`` is part
of the render cache key so a safe ``"<b>"`` and an unsafe ``"<b>"`` never
collide.
"""
if isinstance(child, Node):
return (child._render(), True)
if isinstance(child, str):
return (child, isinstance(child, SafeText))
if hasattr(child, "__html__"):
return (child.__html__(), True)
return (child, False)
return (str(child), False)
+6 -7
View File
@@ -4,9 +4,8 @@ from typing import Any
from django.template.defaultfilters import floatformat
from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe
from common.components.core import Children, Node, as_children
from common.components.core import Children, Node, Safe, as_children
from common.components.primitives import (
A,
Div,
@@ -130,7 +129,7 @@ def LinkedPurchase(purchase: Purchase) -> Node:
),
PopoverTruncated(
input_string=link_content,
popover_content=mark_safe(popover_content),
popover_content=Safe(popover_content),
popover_if_not_truncated=popover_if_not_truncated,
),
],
@@ -210,7 +209,7 @@ def PurchasePrice(purchase) -> Node:
)
def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText:
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
"""Alpine.js dropdown to change a game's status."""
options_html = "\n".join(
f"<template x-if=\"status == '{value}'\">"
@@ -228,7 +227,7 @@ def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText:
for value, label in game_statuses
)
return mark_safe(f"""
return Safe(f"""
<div class="flex gap-2 items-center"
x-data="{{
status: '{game.status}',
@@ -261,7 +260,7 @@ def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText:
""")
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> SafeText:
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
"""Alpine.js dropdown to change a session's device."""
device_id = session.device_id or "null"
device_name = (session.device.name if session.device else "Unknown").replace(
@@ -276,7 +275,7 @@ def SessionDeviceSelector(session, session_devices, csrf_token: str) -> SafeText
for d in session_devices
)
return mark_safe(f"""
return Safe(f"""
<div class="flex gap-2 items-center"
x-data="{{
originalDeviceId: {device_id},
+12 -8
View File
@@ -15,6 +15,7 @@ from django.utils.safestring import SafeText, mark_safe
from common.components.core import (
Attributes,
Child,
Children,
Element,
Fragment,
@@ -80,7 +81,7 @@ Th = _html_element("th")
def _popover_html(
id: str,
popover_content: str,
popover_content: Child,
wrapped_content: str = "",
wrapped_classes: str = "",
slot: "Node | str" = "",
@@ -130,14 +131,14 @@ def _popover_html(
def Popover(
popover_content: str,
popover_content: Child,
wrapped_content: str = "",
wrapped_classes: str = "",
children: Children = None,
attributes: Attributes | None = None,
id: str = "",
) -> Node:
children = children or []
children = as_children(children)
if not wrapped_content and not children:
raise ValueError("One of wrapped_content or children is required.")
if not id:
@@ -155,7 +156,7 @@ def Popover(
def PopoverTruncated(
input_string: str,
popover_content: str = "",
popover_content: Child = "",
popover_if_not_truncated: bool = False,
length: int = 30,
ellipsis: str = "",
@@ -507,9 +508,12 @@ def Pill(
return Span(attributes=pill_attrs, children=children)
def CsrfInput(request) -> SafeText:
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag."""
return mark_safe(
def CsrfInput(request) -> Node:
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag.
Returns a ``Safe`` node (not a safe string): it is always used as a tree
child, and only nodes render unescaped now."""
return Safe(
f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">'
)
@@ -958,7 +962,7 @@ def _pagination_nav(page_obj, elided_page_range, request) -> str:
def SimpleTable(
columns: list[str] | None = None,
rows: list | None = None,
header_action: SafeText | str | None = None,
header_action: Child | None = None,
page_obj=None,
elided_page_range=None,
request=None,