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