19 KiB
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_label → status_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 сохраняется
как есть: будучи экранированным на границе (<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.py —
fmt_tokens/fmt_cost/short_model_name остаются как есть; defence-in-depth делается на
потребителе, как зафиксировано в ТЗ §2).
Почему граница рендера, а не источник. (1) Single-responsibility: _fmt_minutes и
short_model_name используются и вне HTML-контекста (логи, потенциально иные потребители) —
вшивать < в их вывод сделало бы данные «грязными» в не-HTML-контексте. (2) Инвариант FR-2
формулируется и тестируется как свойство ОДНОЙ функции (render_task_tracker): «ни один символ
< > & из данных не остаётся неэкранированным в выходе» — а не как разрозненные контракты пяти
источников. (3) Экранирование на границе по построению исключает двойное экранирование: каждый
D-слот экранируется в ровно одной точке; M-слоты не экранируются вовсе.
Инвариант D1: видимый оператору формат всех D-полей не меняется (escape <1м→<1м
рендерится как <1м; ~Nм, Nм, токены/стоимость/модель символов < > & не содержат →
escape для них no-op).
D2 — Сохранение <1м в источнике; формат-источник _fmt_minutes не меняется (⇒ FR-1, BR-3)
BR-3/FR-1 допускают два пути: (а) экранировать <1м, либо (б) переформулировать (~0м /
< 1 мин). Выбираем (а): _fmt_minutes продолжает возвращать "<1м", безопасность даёт
escape на границе (D1). Это минимизирует поверхность изменения (никаких правок числовой/строковой
логики _fmt_minutes, _capped_review_str, тестов формата длительности) и сохраняет видимый
оператору вид <1м без согласования новой формулировки. _fmt_minutes сохраняет never-raise
(нечисловой/None → 0м) без изменений.
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→ новой карточки нет. После фикса D1–D3 источник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)) уже экранированы — повторно
их не экранируем (иначе &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возвращает<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 (нет сырого< > &из данных И нет&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