fix: drawer delete-impact modal HTML escapes user-controllable fields (T110.2)

The delete-impact modal is built via raw f-string concatenation from the
ImpactReport — item.kind / item.description / report.notes ultimately
embed user-controllable content (turn prose, scene timestamps). A turn
with prose like "<script>alert(1)</script>" would reach the rendered
HTML verbatim. Currently safe (the fields embedded today are bounded
strings) but defense-in-depth — wrap with html.escape() so future
description changes can't smuggle markup through.

Test: tests/test_drawer_phase4.py::test_delete_impact_modal_escapes_user_controllable_strings.
This commit is contained in:
Joseph Doherty
2026-04-27 05:12:28 -04:00
parent f3827706df
commit a45a33534f
2 changed files with 51 additions and 3 deletions
+15 -3
View File
@@ -27,6 +27,7 @@ one so a later inverse edit can restore state (§6.4 final paragraph).
from __future__ import annotations
import html
import json
import uuid
from pathlib import Path
@@ -1238,18 +1239,29 @@ async def delete_preview(
# reusing the drawer template would require a fragment include just
# for this surface. Mirrors the rewind-preview style in
# :func:`chat.web.turns.rewind_preview`.
#
# T110.2: ``item.kind``, ``item.description``, and the notes carry
# user-controllable content (turn prose, scene timestamps, etc.).
# Wrap them with :func:`html.escape` so a payload like
# ``<script>alert(1)</script>`` renders as inert text. ``chat_id``
# is matched against the projected ``chats`` table at request time
# (404 above) so it isn't free-form, but we escape it for symmetry.
items_html = "".join(
f"<li><strong>{item.kind}</strong>: {item.description}</li>"
f"<li><strong>{html.escape(item.kind)}</strong>: "
f"{html.escape(item.description)}</li>"
for item in report.cascading
)
notes_html = "".join(f"<li>{note}</li>" for note in report.notes)
notes_html = "".join(
f"<li>{html.escape(note)}</li>" for note in report.notes
)
body = (
"<div class='delete-impact-modal'>"
f"<h3>Delete event {report.target_event_id}?</h3>"
f"<p>This will discard {len(report.cascading)} events. Cascade:</p>"
f"<ul class='delete-impact-cascade'>{items_html or '<li>none</li>'}</ul>"
f"<ul class='delete-impact-notes'>{notes_html}</ul>"
f"<form hx-post='/chats/{chat_id}/drawer/turn/delete/{report.target_event_id}' "
f"<form hx-post='/chats/{html.escape(chat_id)}/drawer/turn/delete/"
f"{report.target_event_id}' "
"hx-target='#drawer' hx-swap='innerHTML'>"
"<button type='submit'>Confirm delete</button>"
"</form>"