diff --git a/chat/web/drawer.py b/chat/web/drawer.py
index f0c3ddb..a29f281 100644
--- a/chat/web/drawer.py
+++ b/chat/web/drawer.py
@@ -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
+ # ```` 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"
{item.kind}: {item.description}"
+ f"{html.escape(item.kind)}: "
+ f"{html.escape(item.description)}"
for item in report.cascading
)
- notes_html = "".join(f"{note}" for note in report.notes)
+ notes_html = "".join(
+ f"{html.escape(note)}" for note in report.notes
+ )
body = (
""
f"
Delete event {report.target_event_id}?
"
f"
This will discard {len(report.cascading)} events. Cascade:
"
f"
"
f"
"
- f"
"
diff --git a/tests/test_drawer_phase4.py b/tests/test_drawer_phase4.py
index f4f3235..0b2b473 100644
--- a/tests/test_drawer_phase4.py
+++ b/tests/test_drawer_phase4.py
@@ -458,6 +458,42 @@ def test_t98_4_delete_invokes_rewind_and_drops_cascade(client, tmp_path):
assert row is None, f"event {ev_id} should have been deleted"
+def test_delete_impact_modal_escapes_user_controllable_strings(client, tmp_path):
+ """T110.2: defense-in-depth — fields embedded in the modal HTML come
+ from event payloads (turn prose, scene timestamps, etc.) which are
+ ultimately user-controllable. Wrap them with ``html.escape`` so a
+ payload like ```` renders as inert text and
+ doesn't leak through into the rendered modal as actual markup.
+ """
+ db = tmp_path / "test.db"
+ _seed_chat(db)
+
+ # Seed a user_turn whose prose contains an HTML-script payload. The
+ # modal renders ``description = "turn N (you:
)"`` so
+ # the prose flows verbatim into the cascade list .
+ with open_db(db) as conn:
+ evil_id = append_and_apply(
+ conn,
+ kind="user_turn",
+ payload={
+ "chat_id": "chat_bot_a",
+ "prose": "",
+ "segments": [],
+ },
+ )
+
+ response = client.get(
+ f"/chats/chat_bot_a/drawer/turn/delete-preview/{evil_id}"
+ )
+ assert response.status_code == 200
+ body = response.text
+
+ # Raw