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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user