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

Closed
opened 2026-07-05 18:13:42 +03:00 by agent_vscode · 1 comment
Collaborator

Часть эпика «Рефакторинг пайплайна рендера» (см. эпик-ишью, этап 1 из 6, выполняется первым).

Цель

Один конфиг bleach на весь проект, один санитайз на пост, ноль thread-hop'ов в циклах. Сейчас конфиг скопирован в трёх местах и уже разъехался, а RSS-путь делает отдельный asyncio.to_thread на каждый пост.

Намеренные изменения поведения (фиксируются этим этапом)

  1. Багфикс: теги s, del разрешены и в фидах (сейчас зачёркивание выживает только в одиночном посте: post_parser._sanitize_html их разрешает, обе копии в rss_generator — нет).
  2. Security-фикс: PostParser._sanitize_html при исключении bleach возвращал сырой HTML (fail-open) → теперь html.escape (fail-closed).
  3. Багфикс: <hr class="post-divider"> в HTML-фиде теперь реально виден — сейчас он добавляется ДО финального санитайза, а hr нет в whitelist, и bleach со strip=True молча вырезает все разделители.

Новый файл 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 today — move as-is from post_parser._sanitize_html
ALLOWED_CSS_PROPERTIES = ["max-width", "max-height", "object-fit", "width", "height"]

_CSS_SANITIZER = CSSSanitizer(allowed_css_properties=ALLOWED_CSS_PROPERTIES)  # stateless config, safe to share

def sanitize_html(html_raw: str) -> str:
    """Sanitize one HTML fragment. FAIL-CLOSED: on any bleach error the
    fragment is html.escape()d, never returned raw (stored-XSS guard)."""
    # keep the >0.05s slow-call warning log (diag_sanitize_slow) from post_parser here

Правки

  1. post_parser.py _sanitize_html (~строки 702–737) → однострочный делегат в sanitizer.sanitize_html. Метод оставить — его зовёт process_message. Ветка return html_raw при исключении исчезает (фикс №2).
  2. rss_generator.py _render_pipeline (~строка 344): после _render_messages_groups добавить for p in posts: p['html'] = sanitize_html(p['html']). Комментарий: sanitize runs here because the whole pipeline already executes in a worker thread.
  3. rss_generator.py цикл RSS-entries (~строки 458–497): удалить вложенный _sanitize_sync, try/except и to_threadfe.content(content=post['html'], type='CDATA') напрямую.
  4. rss_generator.py HTML-путь (~строки 671–705): удалить _concat_html+to_thread и _sanitize_sync+to_thread; остаётся html = '\n<hr class="post-divider">\n'.join(p['html'] for p in final_posts) — join ПОСЛЕ санитайза (фикс №3).
  5. Подчистить неиспользуемые импорты bleach/CSSSanitizer в rss_generator.py.
  6. Обновить устаревшие комментарии про «4.4 coverage map»/границы санитайза в обоих файлах (граница теперь: per-post в _render_pipeline для фидов; process_message(sanitize=True) для одиночного поста — путь get_post НЕ трогать).

Крайние случаи

  • Одиночный «ядовитый» пост (bleach бросает исключение) теперь эскейпится точечно, не валя весь фид.
  • fe.description(post['text']...) не трогать — feedgen сам XML-эскейпит.

Тесты

Новый tests/test_sanitizer.py:

  • (a) s/del выживают после sanitize_html;
  • (b) script/onerror вырезаются;
  • (c) fail-closed: mock bleach → exception → результат эскейпнут, не сырой;
  • (d) интеграционно: в выводе generate_channel_html присутствует <hr class="post-divider">.

Прогнать tests/test_stage4_eventloop.py — он закрепляет старые границы санитайза; при конфликте обновить тест с комментарием-ссылкой на номер фикса из списка выше.

DoD

  • grep по репо находит ровно одно определение allowed_tags;
  • в rss_generator.py нет прямых вызовов bleach;
  • pytest полностью зелёный.

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

Ветка refactor/render-pipeline (от feat/kurigram-2.2.23 после коммита её текущих правок), один этап = один коммит, комментарии в коде только на английском, точечные правки без переписывания файлов целиком.

Часть эпика «Рефакторинг пайплайна рендера» (см. эпик-ишью, этап 1 из 6, выполняется первым). ## Цель Один конфиг bleach на весь проект, один санитайз на пост, ноль thread-hop'ов в циклах. Сейчас конфиг скопирован в трёх местах и уже разъехался, а RSS-путь делает отдельный `asyncio.to_thread` на каждый пост. ## Намеренные изменения поведения (фиксируются этим этапом) 1. **Багфикс:** теги `s`, `del` разрешены и в фидах (сейчас зачёркивание выживает только в одиночном посте: `post_parser._sanitize_html` их разрешает, обе копии в `rss_generator` — нет). 2. **Security-фикс:** `PostParser._sanitize_html` при исключении bleach возвращал **сырой** HTML (fail-open) → теперь `html.escape` (fail-closed). 3. **Багфикс:** `<hr class="post-divider">` в HTML-фиде теперь реально виден — сейчас он добавляется ДО финального санитайза, а `hr` нет в whitelist, и bleach со `strip=True` молча вырезает все разделители. ## Новый файл `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 today — move as-is from post_parser._sanitize_html ALLOWED_CSS_PROPERTIES = ["max-width", "max-height", "object-fit", "width", "height"] _CSS_SANITIZER = CSSSanitizer(allowed_css_properties=ALLOWED_CSS_PROPERTIES) # stateless config, safe to share def sanitize_html(html_raw: str) -> str: """Sanitize one HTML fragment. FAIL-CLOSED: on any bleach error the fragment is html.escape()d, never returned raw (stored-XSS guard).""" # keep the >0.05s slow-call warning log (diag_sanitize_slow) from post_parser here ``` ## Правки 1. `post_parser.py` `_sanitize_html` (~строки 702–737) → однострочный делегат в `sanitizer.sanitize_html`. Метод оставить — его зовёт `process_message`. Ветка `return html_raw` при исключении исчезает (фикс №2). 2. `rss_generator.py` `_render_pipeline` (~строка 344): после `_render_messages_groups` добавить `for p in posts: p['html'] = sanitize_html(p['html'])`. Комментарий: sanitize runs here because the whole pipeline already executes in a worker thread. 3. `rss_generator.py` цикл RSS-entries (~строки 458–497): удалить вложенный `_sanitize_sync`, `try/except` и `to_thread` — `fe.content(content=post['html'], type='CDATA')` напрямую. 4. `rss_generator.py` HTML-путь (~строки 671–705): удалить `_concat_html`+`to_thread` и `_sanitize_sync`+`to_thread`; остаётся `html = '\n<hr class="post-divider">\n'.join(p['html'] for p in final_posts)` — join ПОСЛЕ санитайза (фикс №3). 5. Подчистить неиспользуемые импорты bleach/CSSSanitizer в `rss_generator.py`. 6. Обновить устаревшие комментарии про «4.4 coverage map»/границы санитайза в обоих файлах (граница теперь: per-post в `_render_pipeline` для фидов; `process_message(sanitize=True)` для одиночного поста — путь `get_post` НЕ трогать). ## Крайние случаи - Одиночный «ядовитый» пост (bleach бросает исключение) теперь эскейпится точечно, не валя весь фид. - `fe.description(post['text']...)` не трогать — feedgen сам XML-эскейпит. ## Тесты Новый `tests/test_sanitizer.py`: - (a) `s`/`del` выживают после sanitize_html; - (b) `script`/`onerror` вырезаются; - (c) fail-closed: mock bleach → exception → результат эскейпнут, не сырой; - (d) интеграционно: в выводе `generate_channel_html` присутствует `<hr class="post-divider">`. Прогнать `tests/test_stage4_eventloop.py` — он закрепляет старые границы санитайза; при конфликте обновить тест с комментарием-ссылкой на номер фикса из списка выше. ## DoD - grep по репо находит ровно одно определение allowed_tags; - в `rss_generator.py` нет прямых вызовов bleach; - `pytest` полностью зелёный. ## Общие правила эпика Ветка `refactor/render-pipeline` (от `feat/kurigram-2.2.23` после коммита её текущих правок), один этап = один коммит, комментарии в коде только на английском, точечные правки без переписывания файлов целиком.
Author
Collaborator

Устарело: ишью создавалось по v1 спеки. Спека прошла пять раундов адверсариального ревью и дошла до v5 (plans/2026-07-05-render-pipeline-refactor.md) — состав этапа 1 существенно изменился (protocols/strip в sanitizer, §3.16 единое имя санитайз-лога, log_context, зависимость от нового этапа 0 с golden-эталонами). Закрыто как superseded; замена — новый набор ишью по этапам 0–6 спеки v5.

Устарело: ишью создавалось по v1 спеки. Спека прошла пять раундов адверсариального ревью и дошла до v5 (`plans/2026-07-05-render-pipeline-refactor.md`) — состав этапа 1 существенно изменился (protocols/strip в sanitizer, §3.16 единое имя санитайз-лога, log_context, зависимость от нового этапа 0 с golden-эталонами). Закрыто как superseded; замена — новый набор ишью по этапам 0–6 спеки v5.
Sign in to join this conversation.