From c2d9adc45fddc3c633ad9bb8089c1b366644a71a Mon Sep 17 00:00:00 2001 From: agent_coder Date: Mon, 6 Jul 2026 17:01:00 +0300 Subject: [PATCH] =?UTF-8?q?refactor(render):=20#28=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=201=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D1=80=D0=B0?= =?UTF-8?q?=D1=83=D0=BD=D0=B4=201=20=E2=80=94=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=20=D0=B8=D0=B7=D0=BE=D0=BB=D1=8F=D1=86=D0=B8=D0=B8=20=C2=A73.4?= =?UTF-8?q?=20+=20=D0=B0=D0=BA=D1=82=D1=83=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Do 1 [test-coverage]: залочил security-инвариант §3.4 (per-post изоляция фрагмента) именованным тестом test_unbalanced_post_does_not_swallow_next_post_html_feed (в test_stage4_eventloop.py — гоняет реальный per-post HTML-пайплайн на истории из 2 сообщений). Пост A несёт висячий `
DANGLING_A` без закрывашек, пост B целый с маркером. Ассертит: фид бьётся на 2 фрагмента по `
`; теги A сбалансированы В ПРЕДЕЛАХ фрагмента A; маркер B в фрагменте B и НЕ в A (не заглочен). Проверено, что тест краснеет на регрессии «join-before-sanitize»: при склейке raw-постов до единого whole-feed bleach.clean невайтлистнутый `
` стрипается (strip=True) → 0 разделителей → 1 фрагмент → первый ассерт падает. То есть будущий этап, вернувший склейку до санитайза, ловится этим тестом. Do 2 [documentation]: 4 устаревших коммента про удалённый «whole-feed sanitize для HTML» обновлены на «per-post в _render_pipeline для обоих (RSS и HTML)» (post_parser.py process_message docstring / _generate_html_body NOTE / generate_html_footer coverage-map; rss_generator.py feed-path коммент). Вывод «санитайзится ровно один раз» сохранён. Исполняемый код не менялся. Полный сюит 347 passed (+1 тест изоляции); оракул этапа 0 — 11 passed, golden не менялся. Co-Authored-By: Claude Opus 4.8 (1M context) --- post_parser.py | 13 +++++---- rss_generator.py | 5 ++-- tests/test_stage4_eventloop.py | 50 ++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/post_parser.py b/post_parser.py index 3a71d5e..db15bd9 100644 --- a/post_parser.py +++ b/post_parser.py @@ -637,10 +637,10 @@ class PostParser: serializing every post. sanitize: when True, run the html body and footer through a single bleach pass each. Single-post HTML and JSON need this (there is no - whole-feed pass on those paths). Feed generation passes False and - relies on the final sanitize in rss_generator (per-post for RSS, - whole-feed for HTML), so no - fragment is sanitized more than once. + feed-level pass on those paths). Feed generation passes False and + relies on the per-post sanitize in rss_generator._render_pipeline + (per-post for BOTH RSS and HTML), so no fragment is sanitized more + than once. """ # Compute html body once — avoids triple _generate_html_body calls. # The internal per-fragment sanitize passes were removed (4.4); sanitize @@ -799,7 +799,7 @@ class PostParser: # NOTE: sanitize is NOT applied here. Sanitization happens exactly once per # output boundary (process_message for single-post/JSON; in rss_generator the - # per-post pass for RSS and the whole-feed pass for HTML). See the map (4.4). + # per-post pass in _render_pipeline for BOTH RSS and HTML). See the map (4.4). html_body = '\n'.join(content_body) return html_body @@ -1004,7 +1004,8 @@ class PostParser: content_footer.append('
' + flags_html) # Not sanitized here — sanitized once at the output boundary (4.4 coverage map): - # process_message for single-post/JSON; per-post (RSS) / whole-feed (HTML) for feeds. + # process_message for single-post/JSON; per-post in _render_pipeline for feeds + # (both RSS and HTML). html_footer = '\n'.join(content_footer) return html_footer diff --git a/rss_generator.py b/rss_generator.py index b5a6287..1344e4d 100644 --- a/rss_generator.py +++ b/rss_generator.py @@ -220,8 +220,9 @@ def _render_messages_groups(messages_groups: list[list[Message]], try: if len(group) == 1: # Single message - simple case one_message = group[0] - # Feed path: raw_message not needed and sanitize deferred to the final - # whole-feed pass, so each fragment is sanitized exactly once. + # Feed path: raw_message not needed and sanitize deferred to the per-post + # pass in _render_pipeline (both RSS and HTML), so each fragment is + # sanitized exactly once. message_data = post_parser.process_message(one_message, include_raw=False, sanitize=False) html_parts = [ f'
{message_data["html"]["body"]}
', diff --git a/tests/test_stage4_eventloop.py b/tests/test_stage4_eventloop.py index a5ed4d0..90f616f 100644 --- a/tests/test_stage4_eventloop.py +++ b/tests/test_stage4_eventloop.py @@ -383,6 +383,56 @@ async def test_xss_in_media_caption_stripped_in_feeds(monkeypatch): _assert_clean(html_feed, "html feed (media caption)") +# --------------------------------------------------------------------------- # +# §3.4 — per-post sanitize ISOLATION (stage-1 load-bearing invariant, PR #36). +# A dangling/unbalanced tag in post A must be normalized WITHIN A's own fragment and +# cannot swallow post B (cross-post DOM/XSS bleed). This holds because _render_pipeline +# runs a SEPARATE bleach.clean per post and the formatter joins the
divider AFTER +# sanitize. A FUTURE stage that rejoins BEFORE sanitizing would reintroduce the bleed +# and pass every single-message XSS test — so this 2-post case MUST turn it red. +# --------------------------------------------------------------------------- # +@pytest.mark.asyncio +async def test_unbalanced_post_does_not_swallow_next_post_html_feed(monkeypatch): + # Post A carries a dangling
with NO closers; post B is well-formed and + # carries a unique marker. _Str.html feeds the raw tags into the pre-sanitize body + # exactly as a real message with entities would. + msg_a = make_message(50, text="
DANGLING_A no closers here", + date=datetime(2024, 1, 1, 12, 5, 0, tzinfo=timezone.utc)) + msg_b = make_message(51, text="INTACT_B_MARKER", + date=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)) + + async def fake_get_chat(client, channel): + return SimpleNamespace(title="Test", username="testchan", id=-1001234567890) + + async def fake_get_history(client, channel, limit=20): + return [msg_a, msg_b] # A is newer than B -> A rendered first + + monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False) + monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_history, raising=False) + + html = await generate_channel_html("testchan", client=SimpleNamespace(), limit=5) + + # The divider survives (joined AFTER per-post sanitize) and splits the feed into + # exactly two TOP-LEVEL fragments. A join-before-sanitize regression strips the + # non-whitelisted
entirely (strip=True) -> this alone already goes red. + parts = html.split('
') + assert len(parts) == 2, "expected exactly one top-level post-divider between the two posts" + frag_a, frag_b = parts + + # A's dangling tags were balanced WITHIN A's own fragment, NOT deferred past the + # divider. A rejoin-before-sanitize regression closes them only at the very end of + # the whole feed (after B), leaving frag_a with more opens than closes. + assert "DANGLING_A" in frag_a + assert frag_a.count(""), "post A's
not closed within its own fragment" + assert frag_a.count("") == frag_a.count(""), "post A's not closed within its own fragment" + + # B is intact, lives at top level, and is itself balanced — never nested inside A + # (its marker must not have been trapped before A's divider). + assert "INTACT_B_MARKER" in frag_b, "post B content missing/swallowed by post A" + assert "INTACT_B_MARKER" not in frag_a, "post B content bled into post A's fragment" + assert frag_b.count(""), "post B fragment not self-contained" + + # --------------------------------------------------------------------------- # # Review round-1: the new bulk-upsert SQL executed for real (not mocked). # --------------------------------------------------------------------------- #