Рефакторинг рендера [этап 1/6]: sanitizer.py + санитайз per-post внутри _render_pipeline #28

Closed
opened 2026-07-05 20:26:17 +03:00 by agent_vscode · 0 comments
Collaborator

Часть эпика «Рефакторинг пайплайна рендера». Спека: plans/2026-07-05-render-pipeline-refactor.md (v5), раздел «Этап 1». Зависимость: этап 0 (#27) — golden-эталоны должны существовать ДО этого этапа. Заменяет закрытое #22 (писалось по v1 спеки). Line-refs действительны для f9550d8.

Цель

Один конфиг bleach на весь проект, один санитайз на пост, ноль thread-hop'ов в циклах. Сейчас конфиг скопирован трижды и разъехался, RSS-путь делает asyncio.to_thread + новый CSSSanitizer + вложенную функцию НА КАЖДЫЙ пост — при том, что _render_pipeline уже выполняется в worker-треде.

Пункты реестра §3, фиксируемые этим этапом

  • §3.1 (багфикс): s, del разрешены и в фидах (сейчас зачёркивание выживает только в одиночном посте).
  • §3.2 (security): fail-open → fail-closed; затрагивает и single-post/JSON путь.
  • §3.3 (багфикс): <hr class="post-divider"> реально виден в HTML-фиде (сейчас вырезается: добавляется до санитайза, hr не в whitelist, strip=True).
  • §3.4 (багфикс): несбалансированный фрагмент нормализуется в границах СВОЕГО поста и не искажает DOM последующих (только HTML-фид; RSS уже per-post).
  • §3.5 (улучшение): fail-closed гранулярность HTML-фида — эскейпится упавший пост, не весь фид.
  • §3.16 (наблюдаемость): три имени санитайз-логов (html_sanitization_error / rss_html_sanitization_error / html_final_sanitization_error) объединяются в ОДНО — html_sanitization_error + log_context; grep-правила мониторинга обновить при деплое.

Новый модуль sanitizer.py

# sanitizer.py — the ONLY bleach configuration in the project.
ALLOWED_TAGS = ['p', 'a', 'b', 'i', 'strong', 'em', 's', 'del',
                'ul', 'ol', 'li', 'br', 'div', 'span',
                'img', 'video', 'audio', 'source']          # union of the 3 old copies
ALLOWED_ATTRIBUTES = { ... }        # identical in all 3 copies — move as-is from post_parser
ALLOWED_CSS_PROPERTIES = ["max-width", "max-height", "object-fit", "width", "height"]
ALLOWED_PROTOCOLS = ['http', 'https', 'tg']   # non-default! tg:// links in footers

# Shared instance is safe: CSSSanitizer only holds the allowed-properties list
# (stateless config); bleach builds a fresh Cleaner per clean() call anyway.
_CSS_SANITIZER = CSSSanitizer(allowed_css_properties=ALLOWED_CSS_PROPERTIES)

def sanitize_html(html_raw: str, log_context: str = "") -> str:
    """Sanitize one HTML fragment. FAIL-CLOSED: on any bleach error the
    fragment is html.escape()d, never returned raw (stored-XSS guard)."""
    # The FULL call — BOTH non-default params are load-bearing:
    #   clean(html_raw, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES,
    #         protocols=ALLOWED_PROTOCOLS, css_sanitizer=_CSS_SANITIZER,
    #         strip=True)
    # strip=True: disallowed tags are REMOVED (bleach default False would
    # escape them into visible text). strip_comments stays default (True).
    # Error log name: html_sanitization_error (single name, §3.16),
    # log_context distinguishes call sites. Keep the >0.05s
    # diag_sanitize_slow warning with input_len.

Правки

  1. post_parser.py::_sanitize_html (branch:702–737) → делегат в sanitizer.sanitize_html. Fail-open ветка return html_raw исчезает (§3.2). Метод оставить — его зовёт process_message.
  2. rss_generator.py::_render_pipeline (branch:324–344): добавить параметр channel (только для логов); после _render_messages_groups (т.е. ПОСЛЕ фильтров — не тратить bleach на отфильтрованное): p['html'] = sanitize_html(p['html'], log_context=f"channel {channel}, message_id {p['message_id']}"). ВАЖНО: близнецы ещё не слиты — channel добавить в ОБА вызова to_thread(_render_pipeline, …) (branch:433 и 657).
  3. RSS-цикл (branch:458–497): удалить вложенный _sanitize_sync + to_thread; fe.content(content=post['html'], type='CDATA') напрямую. fe.description(post['text']...) не трогать — feedgen сам XML-эскейпит.
  4. HTML-путь (branch:671–705): удалить _concat_html/_sanitize_sync + оба to_thread; html = '\n<hr class="post-divider">\n'.join(...) — join ПОСЛЕ санитайза.
  5. Импорты bleach/CSSSanitizer из rss_generator удалить. test_stage4_eventloop.py:474 monkeypatch'ит rss_module.HTMLSanitizer — перенацелить на модуль sanitizer (пометка «API relocation, no behavior change»).
  6. Обновить golden (§3 пп.1, 3, 4; пп.2 и 5 golden не видит — они верифицируются юнит-тестами) и устаревшие комментарии о границах санитайза («4.4 coverage map» и т.п.). Путь get_post (одиночный пост) НЕ трогать.

Тесты

tests/test_sanitizer.py: s/del выживают; script/onerror ВЫРЕЗАЮТСЯ (не эскейпятся в текст — проверка strip=True); tg:// href выживает; fail-closed при исключении (mock bleach → exception → эскейпнутый результат, лог с именем html_sanitization_error); <hr class="post-divider"> присутствует в выводе generate_channel_html.

DoD

Одно определение allowed_tags в репо; в rss_generator нет прямых вызовов bleach; pytest + golden зелёные (golden-дифф — только пп.1/3/4 с комментарием).

Общие правила эпика

Ветка refactor/render-pipeline от f9550d8; один этап = один коммит; комментарии — English; точечные правки; правки тестов — только по реестру §3 или как API relocation с пометкой.

Часть эпика «Рефакторинг пайплайна рендера». Спека: `plans/2026-07-05-render-pipeline-refactor.md` (v5), раздел «Этап 1». Зависимость: этап 0 (#27) — golden-эталоны должны существовать ДО этого этапа. Заменяет закрытое #22 (писалось по v1 спеки). Line-refs действительны для f9550d8. ## Цель Один конфиг bleach на весь проект, один санитайз на пост, ноль thread-hop'ов в циклах. Сейчас конфиг скопирован трижды и разъехался, RSS-путь делает `asyncio.to_thread` + новый `CSSSanitizer` + вложенную функцию НА КАЖДЫЙ пост — при том, что `_render_pipeline` уже выполняется в worker-треде. ## Пункты реестра §3, фиксируемые этим этапом - §3.1 (багфикс): `s`, `del` разрешены и в фидах (сейчас зачёркивание выживает только в одиночном посте). - §3.2 (security): fail-open → fail-closed; затрагивает и single-post/JSON путь. - §3.3 (багфикс): `<hr class="post-divider">` реально виден в HTML-фиде (сейчас вырезается: добавляется до санитайза, `hr` не в whitelist, strip=True). - §3.4 (багфикс): несбалансированный фрагмент нормализуется в границах СВОЕГО поста и не искажает DOM последующих (только HTML-фид; RSS уже per-post). - §3.5 (улучшение): fail-closed гранулярность HTML-фида — эскейпится упавший пост, не весь фид. - §3.16 (наблюдаемость): три имени санитайз-логов (`html_sanitization_error` / `rss_html_sanitization_error` / `html_final_sanitization_error`) объединяются в ОДНО — `html_sanitization_error` + log_context; grep-правила мониторинга обновить при деплое. ## Новый модуль `sanitizer.py` ```python # sanitizer.py — the ONLY bleach configuration in the project. ALLOWED_TAGS = ['p', 'a', 'b', 'i', 'strong', 'em', 's', 'del', 'ul', 'ol', 'li', 'br', 'div', 'span', 'img', 'video', 'audio', 'source'] # union of the 3 old copies ALLOWED_ATTRIBUTES = { ... } # identical in all 3 copies — move as-is from post_parser ALLOWED_CSS_PROPERTIES = ["max-width", "max-height", "object-fit", "width", "height"] ALLOWED_PROTOCOLS = ['http', 'https', 'tg'] # non-default! tg:// links in footers # Shared instance is safe: CSSSanitizer only holds the allowed-properties list # (stateless config); bleach builds a fresh Cleaner per clean() call anyway. _CSS_SANITIZER = CSSSanitizer(allowed_css_properties=ALLOWED_CSS_PROPERTIES) def sanitize_html(html_raw: str, log_context: str = "") -> str: """Sanitize one HTML fragment. FAIL-CLOSED: on any bleach error the fragment is html.escape()d, never returned raw (stored-XSS guard).""" # The FULL call — BOTH non-default params are load-bearing: # clean(html_raw, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES, # protocols=ALLOWED_PROTOCOLS, css_sanitizer=_CSS_SANITIZER, # strip=True) # strip=True: disallowed tags are REMOVED (bleach default False would # escape them into visible text). strip_comments stays default (True). # Error log name: html_sanitization_error (single name, §3.16), # log_context distinguishes call sites. Keep the >0.05s # diag_sanitize_slow warning with input_len. ``` ## Правки 1. `post_parser.py::_sanitize_html` (branch:702–737) → делегат в `sanitizer.sanitize_html`. Fail-open ветка `return html_raw` исчезает (§3.2). Метод оставить — его зовёт `process_message`. 2. `rss_generator.py::_render_pipeline` (branch:324–344): добавить параметр `channel` (только для логов); после `_render_messages_groups` (т.е. ПОСЛЕ фильтров — не тратить bleach на отфильтрованное): `p['html'] = sanitize_html(p['html'], log_context=f"channel {channel}, message_id {p['message_id']}")`. ВАЖНО: близнецы ещё не слиты — `channel` добавить в ОБА вызова `to_thread(_render_pipeline, …)` (branch:433 и 657). 3. RSS-цикл (branch:458–497): удалить вложенный `_sanitize_sync` + `to_thread`; `fe.content(content=post['html'], type='CDATA')` напрямую. `fe.description(post['text']...)` не трогать — feedgen сам XML-эскейпит. 4. HTML-путь (branch:671–705): удалить `_concat_html`/`_sanitize_sync` + оба `to_thread`; `html = '\n<hr class="post-divider">\n'.join(...)` — join ПОСЛЕ санитайза. 5. Импорты bleach/CSSSanitizer из rss_generator удалить. test_stage4_eventloop.py:474 monkeypatch'ит `rss_module.HTMLSanitizer` — перенацелить на модуль `sanitizer` (пометка «API relocation, no behavior change»). 6. Обновить golden (§3 пп.1, 3, 4; пп.2 и 5 golden не видит — они верифицируются юнит-тестами) и устаревшие комментарии о границах санитайза («4.4 coverage map» и т.п.). Путь `get_post` (одиночный пост) НЕ трогать. ## Тесты `tests/test_sanitizer.py`: `s`/`del` выживают; `script`/`onerror` ВЫРЕЗАЮТСЯ (не эскейпятся в текст — проверка strip=True); `tg://` href выживает; fail-closed при исключении (mock bleach → exception → эскейпнутый результат, лог с именем `html_sanitization_error`); `<hr class="post-divider">` присутствует в выводе `generate_channel_html`. ## DoD Одно определение allowed_tags в репо; в rss_generator нет прямых вызовов bleach; pytest + golden зелёные (golden-дифф — только пп.1/3/4 с комментарием). ## Общие правила эпика Ветка `refactor/render-pipeline` от f9550d8; один этап = один коммит; комментарии — English; точечные правки; правки тестов — только по реестру §3 или как API relocation с пометкой.
Sign in to join this conversation.