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:
@@ -55,10 +55,10 @@ class ComponentCacheTest(unittest.TestCase):
|
||||
self.assertEqual(components._render_element.cache_parameters()["maxsize"], 4096)
|
||||
|
||||
def test_safe_and_unsafe_children_do_not_collide(self):
|
||||
"""A SafeText "<b>" and a plain "<b>" are equal as strings but must
|
||||
render differently — the cache key must keep them distinct."""
|
||||
"""A Safe-node ``<b>`` and a plain-string ``<b>`` render differently —
|
||||
the cache key must keep them distinct."""
|
||||
safe = str(
|
||||
components.Element(tag_name="span", children=[mark_safe("<b>x</b>")])
|
||||
components.Element(tag_name="span", children=[components.Safe("<b>x</b>")])
|
||||
)
|
||||
unsafe = str(components.Element(tag_name="span", children=["<b>x</b>"]))
|
||||
self.assertIn("<b>x</b>", safe)
|
||||
@@ -350,13 +350,23 @@ class ComponentEdgeCasesTest(unittest.TestCase):
|
||||
self.assertNotIn("<script>", result)
|
||||
self.assertIn("<script>", result)
|
||||
|
||||
def test_mark_safe_children_pass_through(self):
|
||||
def test_safe_node_children_pass_through(self):
|
||||
result = str(
|
||||
components.Element(
|
||||
tag_name="div", children=[components.Safe("<span>safe</span>")]
|
||||
)
|
||||
)
|
||||
self.assertIn("<span>safe</span>", result)
|
||||
|
||||
def test_mark_safe_string_children_are_escaped(self):
|
||||
# Trusted markup must be a Safe node; a mark_safe string is still a
|
||||
# string, so it is escaped like any other text child.
|
||||
result = str(
|
||||
components.Element(
|
||||
tag_name="div", children=[mark_safe("<span>safe</span>")]
|
||||
)
|
||||
)
|
||||
self.assertIn("<span>safe</span>", result)
|
||||
self.assertIn("<span>safe</span>", result)
|
||||
|
||||
def test_attribute_values_are_escaped(self):
|
||||
result = str(
|
||||
@@ -840,14 +850,12 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
||||
|
||||
def test_simple_table_header_action_as_caption(self):
|
||||
"""Verify header_action renders inside <caption>."""
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
result = str(
|
||||
str(
|
||||
components.SimpleTable(
|
||||
columns=["Game", "Started"],
|
||||
rows=[["Game1", "2025-01-01"]],
|
||||
header_action=mark_safe('<a href="/add">Add</a>'),
|
||||
header_action=components.Safe('<a href="/add">Add</a>'),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user