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