Files
agent_coder 0963c140a3 refactor(render): этап 1 — sanitizer.py + санитайз per-post в пайплайне (#28)
Часть эпика рефакторинга рендер-пайплайна (#34). Один bleach-конфиг на весь
проект, один санитайз на пост, ноль thread-hop'ов в циклах (раньше конфиг был
скопирован трижды и разъехался; RSS-путь делал to_thread + новый CSSSanitizer +
вложенную функцию НА КАЖДЫЙ пост, хотя _render_pipeline уже в worker-треде).

Новый sanitizer.py — единственная bleach-конфигурация: ALLOWED_TAGS (union трёх
старых копий, вкл. s/del), ALLOWED_PROTOCOLS=['http','https','tg'],
sanitize_html() зовёт bleach.clean(..., protocols=..., strip=True) (оба не-дефолт),
fail-CLOSED (html.escape при любом исключении), единое имя лога
html_sanitization_error + log_context. _render_pipeline санитайзит каждый пост
после рендера (в своём worker-треде); rss_generator −71 строка.

Реестр §3:
- §3.1 s/del теперь в фидах (раньше выживали только в одиночном посте);
- §3.2 fail-open → fail-closed (вкл. single-post/JSON путь);
- §3.3 <hr class="post-divider"> реально виден (join ПОСЛЕ санитайза; hr не в
  whitelist, поэтому раньше вырезался strip=True);
- §3.4 несбалансированный фрагмент нормализуется в границах своего поста;
- §3.5 гранулярный fail-closed HTML-фида (падает один пост, не весь фид);
- §3.16 три имени санитайз-логов объединены в html_sanitization_error+log_context.

Golden-эталоны обновлены: КАЖДЫЙ изменённый байт привязан к пункту §3 (net-diff
тегов: только +<s>/§3.1 и +<hr>/§3.3; <div> сбалансирован +/- = §3.4;
после снятия этих токенов + нормализатора все 6 голденов побайтово old==new).
Оракул этапа 0 зелёный. Полный сюит 346 passed (+14 sanitizer-тестов).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 17:05:53 +03:00

89 lines
3.6 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# flake8: noqa
# pylint: disable=broad-exception-caught, logging-fstring-interpolation, line-too-long
"""The ONLY bleach configuration in the project.
Before this module the sanitize config was copy-pasted three times (the single-post
path in post_parser plus the RSS and HTML feed paths in rss_generator) and had
drifted: `s`/`del` survived only in the single-post copy, and the three error-log
names diverged. Here the config lives exactly once; every render path routes
through sanitize_html().
"""
import logging
import time as _time
from html import escape as _html_escape
# Imported under this name so tests can monkeypatch `sanitizer.HTMLSanitizer`
# (API relocation from rss_generator — no behaviour change).
from bleach import clean as HTMLSanitizer
from bleach.css_sanitizer import CSSSanitizer
logger = logging.getLogger(__name__)
# Union of the three former copies. `s`/`del` (strikethrough) were previously
# allowed only in the single-post path; registry §3.1 makes them survive in feeds too.
ALLOWED_TAGS = ['p', 'a', 'b', 'i', 'strong', 'em', 's', 'del',
'ul', 'ol', 'li', 'br', 'div', 'span',
'img', 'video', 'audio', 'source']
# Identical in all three former copies — moved here as-is.
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title', 'target'],
'img': ['src', 'alt', 'style'],
'video': ['controls', 'src', 'style'],
'audio': ['controls', 'style'],
'source': ['src', 'type'],
'div': ['class', 'style'],
'span': ['class'],
}
ALLOWED_CSS_PROPERTIES = ["max-width", "max-height", "object-fit", "width", "height"]
# Non-default! bleach's default protocol list would strip tg:// links (channel
# footers / service links use them). Load-bearing.
ALLOWED_PROTOCOLS = ['http', 'https', 'tg']
# 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 — registry §3.2). log_context (e.g. "channel X, message_id
Y") is included in the error/slow logs to keep operational grep-ability across
call sites.
"""
sanitize_start = _time.monotonic()
try:
# Both non-default params are load-bearing:
# protocols=ALLOWED_PROTOCOLS keeps tg:// links alive;
# strip=True REMOVES disallowed tags (bleach's default False would escape
# them into visible text). strip_comments stays default (True), matching
# all former call sites.
sanitized_html = HTMLSanitizer(
html_raw,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
protocols=ALLOWED_PROTOCOLS,
css_sanitizer=_CSS_SANITIZER,
strip=True,
)
except Exception as e:
# Single error-log name across all call sites (registry §3.16); log_context
# distinguishes them. Fail-closed: escape rather than emit the raw payload.
_ctx = f"{log_context}, " if log_context else ""
logger.error(f"html_sanitization_error: {_ctx}error {str(e)}")
return _html_escape(html_raw)
elapsed = _time.monotonic() - sanitize_start
if elapsed > 0.05:
_ctx = f", {log_context}" if log_context else ""
logger.warning(f"diag_sanitize_slow: bleach.clean() took {elapsed:.3f}s, input_len={len(html_raw)}{_ctx}")
return sanitized_html