fix: escape debug title (XSS) and make test suite order-independent
Docker Image CI / build (pull_request) Has been cancelled

Issue #13: data["html"]["title"] was embedded into the debug HTML of
/post/html without escaping; title never passes through bleach, so a post
whose generated title carries markup (e.g. a poll question) was a reflected
XSS under ?debug=true. Escape it with html.escape, same as raw_message.
Add a regression test with a <script> payload in a poll question.

Issue #17: a bare 'pytest' from the repo root failed 22 tests because the
config in tests/pytest.ini was not picked up (asyncio_mode lost, .venv
collected) and every test module polluted sys.modules['config'] at import
time. Move the config to a root pytest.ini with testpaths=tests, and
centralize the sys.path bootstrap + config mock in tests/conftest.py, which
pytest imports before any test module. Drop the per-module preambles.

Closes #13, closes #17

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
vvzvlad
2026-07-05 17:20:01 +03:00
parent 39d5001bd6
commit f13d1507ad
13 changed files with 44 additions and 62 deletions
+4 -1
View File
@@ -486,7 +486,10 @@ class PostParser:
html_content = []
if debug:
html_content.append(f'<div class="title">Title: {data["html"]["title"]}</div><br>')
# title comes from _generate_title (user-controlled post text) and never goes
# through bleach — escape it before embedding, same as raw_message below.
title_escaped = html.escape(str(data["html"]["title"]))
html_content.append(f'<div class="title">Title: {title_escaped}</div><br>')
html_content.append(f'<div class="message-body">{data["html"]["body"]}</div>')
html_content.append(f'<div class="message-footer">{data["html"]["footer"]}</div>')
+2 -1
View File
@@ -1,8 +1,9 @@
[pytest]
testpaths = tests
addopts = -ra
# The stage-1 anti-hang tests are async; run them without per-test event loops
# being set up by hand. (The tests also carry @pytest.mark.asyncio explicitly, so
# they work in strict mode too — but auto keeps the plugin's requirement obvious.)
asyncio_mode = auto
filterwarnings =
ignore::DeprecationWarning
ignore::DeprecationWarning
+17
View File
@@ -0,0 +1,17 @@
"""Shared test bootstrap (issue #17).
Ensure the repo root is importable and install the config mock BEFORE any test
module imports application code. Pytest imports conftest.py before collecting
test modules, so doing this here (instead of a per-module preamble) makes the
suite order-independent regardless of collection order or invocation directory.
"""
import os
import sys
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
import tests.mock_config as _mock_config
sys.modules['config'] = _mock_config
-7
View File
@@ -7,13 +7,6 @@
import unittest
from unittest.mock import MagicMock
import sys
import os
# Add project root to sys.path to find post_parser
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Mock the config module
sys.modules['config'] = __import__('tests.mock_config', fromlist=['get_settings'])
from pyrogram.types import Message
from post_parser import PostParser
-8
View File
@@ -7,14 +7,6 @@
import unittest
from unittest.mock import MagicMock, PropertyMock
import sys
import os
# Add project root to sys.path to find post_parser
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Mock the config module before importing PostParser
sys.modules['config'] = __import__('tests.mock_config', fromlist=['get_settings'])
from pyrogram.types import Message, Chat, Reaction, MessageReactions
from pyrogram.enums import MessageMediaType
-7
View File
@@ -7,13 +7,6 @@
# pylance: disable=reportMissingImports, reportMissingModuleSource
import unittest
from unittest.mock import MagicMock, PropertyMock
import sys
import os
# Add project root to sys.path to find post_parser
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Mock the config module
sys.modules['config'] = __import__('tests.mock_config', fromlist=['get_settings'])
from pyrogram.types import Message, Document
from pyrogram.enums import MessageMediaType
-6
View File
@@ -10,18 +10,12 @@ Stage 1 (anti-hang) regression tests:
worker, and task_done stays balanced so queue.join() completes.
- Gate cancellation during the spacing wait does not lose the permit.
"""
import os
import sys
import time
import asyncio
from types import SimpleNamespace
import pytest
# Add project root to sys.path and mock the config module (same pattern as the other tests).
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
sys.modules['config'] = __import__('tests.mock_config', fromlist=['get_settings'])
import tg_throttle
import tg_cache
from pyrogram import errors
-5
View File
@@ -18,17 +18,12 @@ Covers the four scenarios from the plan plus the subtle in-flight-dedup lifecycl
fresh partials and non-temp files.
"""
import os
import sys
import time
import asyncio
from types import SimpleNamespace
import pytest
# Add project root to sys.path and mock the config module (same pattern as the other tests).
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
sys.modules['config'] = __import__('tests.mock_config', fromlist=['get_settings'])
import api_server
from pyrogram import errors
from pyrogram.enums import MessageMediaType
-5
View File
@@ -36,16 +36,11 @@ All 206 responses that carry data return byte-for-byte identical slices old vs n
real regression is hidden behind an "accepted difference".
"""
import os
import sys
import time
import logging
import pytest
# Add project root to sys.path and mock the config module (same pattern as the other tests).
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
sys.modules['config'] = __import__('tests.mock_config', fromlist=['get_settings'])
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
+21 -5
View File
@@ -16,9 +16,7 @@ Covers:
- 4.4 sanitize coverage (XSS): a <script> / onerror= / javascript: payload is stripped in
ALL outputs — rss, html-feed, single-post html, and json — each with exactly one pass.
"""
import os
import re
import sys
import copy
import pickle
import asyncio
@@ -28,9 +26,6 @@ from types import SimpleNamespace
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
sys.modules['config'] = __import__('tests.mock_config', fromlist=['get_settings'])
from pyrogram.enums import MessageMediaType
import post_parser as pp_module
@@ -289,6 +284,27 @@ async def test_xss_stripped_in_single_post_html_debug():
assert "&lt;script&gt;" in html_out, "debug raw dump was not html-escaped"
@pytest.mark.asyncio
async def test_xss_in_title_escaped_in_debug_html():
# Issue #13: the debug branch of _format_html embeds data["html"]["title"], which is
# generated from user-controlled content and never passes through bleach. A poll whose
# question carries a <script> tag yields the title "📊 Poll: <script>alert(1)</script>"
# verbatim — it must be html-escaped before being embedded in the debug output.
msg = make_message(25, media=MessageMediaType.POLL)
msg.poll = SimpleNamespace(question="<script>alert(1)</script>")
parser = PostParser(_make_client_returning(msg))
html_out = await parser.get_post("testchan", 25, "html", debug=True)
# No live <script> tag anywhere in the output (title div, body, footer, raw <pre>).
assert "<script>" not in html_out, "debug html left a live <script> tag"
# The title div itself carries the payload only in escaped form (proving html.escape
# ran on the title, not just on the raw_message <pre> dump).
title_lines = [line for line in html_out.split("\n") if 'class="title"' in line]
assert title_lines, "debug output must contain the title div"
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in title_lines[0], \
"title was not html-escaped in debug output"
@pytest.mark.asyncio
async def test_xss_stripped_in_rss_feed(monkeypatch):
async def fake_get_chat(client, channel):
-5
View File
@@ -17,17 +17,12 @@ Covers:
- gotcha: str(channel) key discipline — an int-ish channel on the hot path keys the
accumulator (and thus the UPDATE) by the string form.
"""
import os
import sys
import sqlite3
import asyncio
from types import SimpleNamespace
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
sys.modules['config'] = __import__('tests.mock_config', fromlist=['get_settings'])
import api_server
from file_io import init_db_sync, update_media_file_access_bulk_sync
-6
View File
@@ -15,16 +15,10 @@ Covers:
fake client's get_me / safe_get_messages proves neither is ever called.
- TelegramClient.watchdog_last_ok_age(): None when never probed; a positive float afterwards.
"""
import os
import sys
import time
import pytest
# Add project root to sys.path and mock the config module (same pattern as the other tests).
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
sys.modules['config'] = __import__('tests.mock_config', fromlist=['get_settings'])
from fastapi.testclient import TestClient
import api_server
-6
View File
@@ -27,8 +27,6 @@ Automated here:
an int-ish channel records the str-keyed timestamp, and the flush UPDATE matches the
str-keyed DB row (hit -> flush -> DB), the exact affinity gotcha the plan warns about.
"""
import os
import sys
import time
import sqlite3
import asyncio
@@ -36,10 +34,6 @@ from types import SimpleNamespace
import pytest
# Add project root to sys.path and mock the config module (same pattern as the other tests).
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
sys.modules['config'] = __import__('tests.mock_config', fromlist=['get_settings'])
from fastapi import FastAPI
from fastapi.testclient import TestClient