0963c140a3
Часть эпика рефакторинга рендер-пайплайна (#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>
89 lines
3.6 KiB
Python
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
|