Files
orchestrator/docs/work-items/ORCH-095/06-adr/ADR-001-html-safe-card-data-render.md

19 KiB
Raw Permalink Blame History

work_item, stage, author_agent, status, created_at, model_used
work_item stage author_agent status created_at model_used
ORCH-095 architecture architect accepted 2026-06-09 claude-opus-4-8

ADR-001: HTML-безопасный рендер данных live-карточки трекера (устранение инъекции «<1м»)

Work Item: ORCH-095 — HTML-инъекция <1м в render_task_tracker застывает live-карточку Стадия: architecture Сквозная регистрация: N/A — локальное решение задачи. Изменение целиком в слое рендера уведомлений (src/notifications.py); новой стадии/QG/компонента/смены БД нет, инварианты STAGE_TRANSITIONS/QG_CHECKS/схемы не затрагиваются → глобальный adr-NNNN не заводится (прецедент — ORCH-091, такой же indication-only фикс рендера, тоже без сквозного ADR).

Статус

Accepted

Контекст

Live-карточка задачи (src/notifications.py::render_task_tracker) — основной канал видимости конвейера для оператора, инвариант «одна карточка на задачу» (ORCH-042/067/087). Карточка отправляется и редактируется с parse_mode=HTML (send_telegram:58, edit_telegram:175).

Сверено по коду. _fmt_minutes(seconds) (notifications.py:280-290) при 0 < seconds < 60 возвращает литерал "<1м":

if seconds < 60:
    return "<1м"

Эта подстрока интерполируется в HTML-текст карточки без экранирования (_stage_line: dur = _fmt_minutes(dur_sum) → строка f"✅ {label:<13} {dur} · …"; те же _fmt_minutes / _capped_review_str в строке BRD и в итоговой строке времени). Telegram трактует <1м как открывающий HTML-тег → editMessageText отвечает 400 Bad Request: can't parse entities: Unsupported start tag "1м". В edit_telegram неизвестный 400 классифицируется как EDIT_FAILED (notifications.py:203), а update_task_tracker по ветке EDIT_FAILED делает ранний return (анти-дубль ORCH-087) → карточка застывает (воспроизведено детерминированно 09.06 на ORCH-093, message_id 18854).

Корневой класс шире одного <1м. Текст карточки — смесь (а) намеренной разметки-обёртки (<a href> номер задачи num_html, link_for, _done_link; заголовок уже экранирован как esc_title, notifications.py:428) и (б) подставляемых данных. Экранирована только категория-обёртка (href/label в plane_issue_link через html.escape(..., quote=True)) и заголовок. Прочие данные — длительности (_fmt_minutes/_capped_review_str), статус-лейбл (_card_status_labelstatus_label), имя модели (short_model_name), эффорт (_run_effort), токены/стоимость (fmt_tokens/fmt_cost) — вставляются сырыми. <1м — первый сработавший экземпляр класса «неэкранированные данные в HTML-тексте»; ТЗ требует закрыть класс, а не символ (BR-2/FR-2).

«Как есть» не годится: симптом плавающий (ловится только когда хотя бы одна стадия длилась < 60 с и её строка попадает в редактируемый текст), а отказ перманентный для конкретной карточки до конца жизни задачи — оператор слепнет.

Решение

Сводка

Локализуем HTML-безопасность в границе рендера: каждое подставляемое данные-значение экранируется html.escape(...) ровно один раз в точке интерполяции в render_task_tracker; функции-источники данных (_fmt_minutes, short_model_name, _run_effort, fmt_tokens, fmt_cost, _card_status_label) остаются HTML-агностичными (производят данные, не разметку). Намеренная разметка-обёртка (num_html, link_for(...), _done_link, уже-экранированный esc_title) через экранирование не проходит. Литерал <1м в _fmt_minutes сохраняется как есть: будучи экранированным на границе (&lt;1м), он рендерится оператору визуально идентично (<1м) → видимый формат не меняется, согласование формулировки не требуется.

D1 — Точка внесения экранирования: граница рендера, не источник данных (⇒ FR-1, FR-2)

Экранирование делается на потребителе (внутри render_task_tracker/_stage_line), а не внутри функций-источников. Модель «слотов»: текст карточки собирается из слотов двух категорий —

  • Категория M (markup, НЕ экранировать): num_html (plane_issue_link, внутри уже экранированы href+label), link_for(...) в строке « ждёт …», _done_link(...)🔗 PR #n · 📦 Внедрено»), esc_title (уже экранирован в строке 428).
  • Категория D (data, экранировать ровно один раз): dur (_fmt_minutes/_capped_review_str), status_label (_card_status_label), model (short_model_name), effort (_run_effort), in_tok/out_tok (fmt_tokens), cost (fmt_cost), а также числовые attempt и static-лейблы стадий (_TRACKER_STAGES/_BRD_LABEL — статичны и безопасны, но проходят через D ради единообразного инварианта).

Рекомендуемая реализация (необязательна к буквальному следованию — выбор формы за developer): завести тонкий модуль-локальный хелпер def _esc(x): return html.escape(str(x)) (never-raise: на исключении str() → пустая строка/исходный fallback) и обернуть им каждый D-слот в момент присваивания, например dur = _esc(_fmt_minutes(dur_sum)), model = _esc(short_model_name(...)), status_label = _esc(status_label). Источники данных НЕ трогаются (в т.ч. src/usage.pyfmt_tokens/fmt_cost/short_model_name остаются как есть; defence-in-depth делается на потребителе, как зафиксировано в ТЗ §2).

Почему граница рендера, а не источник. (1) Single-responsibility: _fmt_minutes и short_model_name используются и вне HTML-контекста (логи, потенциально иные потребители) — вшивать &lt; в их вывод сделало бы данные «грязными» в не-HTML-контексте. (2) Инвариант FR-2 формулируется и тестируется как свойство ОДНОЙ функции (render_task_tracker): «ни один символ < > & из данных не остаётся неэкранированным в выходе» — а не как разрозненные контракты пяти источников. (3) Экранирование на границе по построению исключает двойное экранирование: каждый D-слот экранируется в ровно одной точке; M-слоты не экранируются вовсе.

Инвариант D1: видимый оператору формат всех D-полей не меняется (escape <1м&lt;1м рендерится как <1м; ~Nм, , токены/стоимость/модель символов < > & не содержат → escape для них no-op).

D2 — Сохранение <1м в источнике; формат-источник _fmt_minutes не меняется (⇒ FR-1, BR-3)

BR-3/FR-1 допускают два пути: (а) экранировать &lt;1м, либо (б) переформулировать (~0м / < 1 мин). Выбираем (а): _fmt_minutes продолжает возвращать "<1м", безопасность даёт escape на границе (D1). Это минимизирует поверхность изменения (никаких правок числовой/строковой логики _fmt_minutes, _capped_review_str, тестов формата длительности) и сохраняет видимый оператору вид <1м без согласования новой формулировки. _fmt_minutes сохраняет never-raise (нечисловой/None → ) без изменений.

D3 — Defence-in-depth: экранируются ВСЕ D-поля, включая сейчас-безопасные (⇒ FR-2, BR-2)

Экранируются все поля категории D, в т.ч. сейчас гарантированно безопасные (fmt_tokens/ fmt_cost дают только цифры/./k/M/$; short_model_name^claude-…$). Стоимость нулевая (escape безопасной строки — no-op), выгода — структурный инвариант: «каждый D-слот карточки экранирован», который защищает от регрессии при будущей смене формата любого источника (напр. если в имя модели/эффорта когда-нибудь попадёт пользовательский ввод). Тест AC-2 ассертит инвариант, а не отдельные поля.

D4 — FR-4 (восстановление застрявших карточек): авто-recovery следующим рендером; парс-фейл НЕ переклассифицируется (⇒ BR-5, FR-4)

Механизм восстановления — достаточное условие по умолчанию из FR-4: после деплоя фикса на ближайшем переходе стадии update_task_tracker рендерит НОВЫЙ безопасный текст и вызывает edit_telegram(mid, new_text) → Telegram отвечает 200 → застрявшая карточка (класс ORCH-093) обновляется на месте. Нового кода не требуется.

Опциональную переклассификацию can't parse entities в edit_telegram/update_task_tracker (переотправка свежей карточки вместо EDIT_FAILED) отвергаем:

  • Не помогает. Если текст всё ещё небезопасен, send_telegram упадёт на том же 400 идентично editMessageText (тот же parse_mode=HTML) и вернёт None → новой карточки нет. После фикса D1D3 источник can't parse entities из НАШИХ данных структурно устранён, поэтому отдельная ветка восстановления лечит несуществующий после фикса случай.
  • Риск. Любое касание ветки EDIT_FAILED/леджера сирот рискует инвариантом ORCH-087 (транзиентный фейл НЕ должен плодить карточки). Минимальная поверхность безопаснее.

edit_telegram, update_task_tracker, send_telegram, леджер tracker_messages, режимы bump/editне трогаются. Known-limitation (унаследовано ORCH-087): для карточки, у которой после фикса больше НЕ будет переходов стадии (задача завершилась до деплоя), повторного рендера не возникнет → карточка остаётся замёрзшей; Telegram-лимит 48ч делает её неперезаписываемой вне окна. BR-5 относится к карточкам в пределах окна с предстоящими переходами.

D5 — Граница «данные vs обёртка»: M-слоты неприкосновенны, двойное экранирование запрещено (⇒ FR-3, BR-4)

num_html (plane_issue_link), link_for(...), _done_link(...) и esc_title через _esc НЕ проходят — остаются валидным HTML, номер задачи кликабелен. Внутренности plane_issue_link (href html.escape(url, quote=True), label html.escape(work_item_id)) уже экранированы — повторно их не экранируем (иначе &amp;lt;, регресс AC-2/AC-3). Граница явная и тестируемая: D-слот → _esc; M-слот → as-is.

D6 — Трассировка и инварианты соседних маркеров (⇒ NFR-2, NFR-3)

render_task_tracker/_stage_line несут маркеры ORCH-042/067/087/091. Изменение ORCH-095 аддитивно к ним и обязано сохранить их инварианты: «одна карточка на задачу», леджер сирот и анти-дубль (ORCH-087), отражение откатов + суммирование метрик _stage_line (ORCH-091), строка Plane-статуса/кликабельный номер (ORCH-067). Поскольку ORCH-095 лишь оборачивает уже вычисленные D-значения в _esc, не меняя ни состава строк, ни порядка, ни логики подавления/суммирования — инварианты сохраняются по построению. Новые/изменённые строки помечаются маркером ORCH-095; блок остаётся читаемым (не вводим 3+ новых маркера в один блок → сводный сквозной ADR не требуется, TRACEABILITY анти-археология соблюдена).

Альтернативы

  • Экранировать в источнике (_fmt_minutes возвращает &lt;1м) — отвергнуто: пачкает данные в не-HTML-контексте (логи), размазывает инвариант FR-2 по пяти функциям, усложняет защиту от двойного экранирования (D1).
  • Переформулировать <1м~0м/< 1 мин — отвергнуто: меняет видимый оператору формат (требует согласования), трогает логику/тесты _fmt_minutes; escape на границе достигает того же при меньшей поверхности и нулевом визуальном изменении (D2).
  • Переключить карточку на parse_mode=None/MarkdownV2 — отвергнуто (вне объёма BRD §6): сломает намеренную разметку (<a href> номер, <b>), MarkdownV2 требует экранирования ещё большего набора символов.
  • Переклассификация can't parse entities → переотправка — отвергнуто (D4): не помогает (send падает идентично), риск инварианту анти-дубля ORCH-087.

Последствия

  • + Класс «неэкранированные данные в HTML-тексте карточки» закрыт целиком (BR-2); <1м и любые будущие < > & из данных безопасны; карточка со стадией < 1 мин редактируется (200).
  • + Структурный defence-in-depth инвариант («каждый D-слот экранирован»), тестируемый одним свойством render_task_tracker (AC-2), устойчив к будущим сменам формата источников.
  • + Видимый формат карточки и намеренная разметка (кликабельный номер, _done_link) без изменений (BR-3/BR-4); никаких миграций/правок схемы/гейтов (NFR-3/NFR-4).
  • + Застрявшие (в окне) карточки авто-восстанавливаются следующим рендером без нового кода (BR-5).
  • Точечная дисциплина «D-слот → _esc, M-слот → as-is» вносит точку для будущих ошибок (можно забыть обернуть новый D-слот или по ошибке обернуть M-слот → двойное экранирование). Митигейшн: тест-инвариант AC-2 (нет сырого < > & из данных И нет &amp;lt;) ловит обе ошибки; явный реестр M-слотов в D5.
  • Карточки задач, завершившихся до деплоя фикса, не восстанавливаются (нет будущего рендера) — known-limitation, унаследовано ORCH-087/Telegram-48ч; вне управляемого.
  • Откат: обычный revert PR (только src/notifications.py + тесты + CHANGELOG.md + doc-правки); прод-контейнер orchestrator не требует ручных операций над данными/БД.

Ссылки

  • BRD: docs/work-items/ORCH-095/01-brd.md
  • TRZ: docs/work-items/ORCH-095/02-trz.md
  • Acceptance: docs/work-items/ORCH-095/03-acceptance-criteria.md
  • Tech-risks: docs/work-items/ORCH-095/10-tech-risks.md
  • Сверено по коду: src/notifications.py (_fmt_minutes:280-290, _capped_review_str:315-336, render_task_tracker:355-610, _stage_line:467-507, _card_status_label:1173-1186, plane_issue_link:932-949, _done_link:613-647, link_for:952-984, edit_telegram:157-207, update_task_tracker:650-746, send_telegram:42-71, esc_title:428)
  • Инварианты соседей: ORCH-042/067 (карточка/номер), ORCH-087 (леджер сирот/анти-дубль), ORCH-091 (откаты/суммирование _stage_line) — docs/architecture/internals.md §7