Skip to content

Commit cae6781

Browse files
Pigbibicodex
andcommitted
Harden upstream RSS XML parsing
Co-Authored-By: Codex <noreply@openai.com>
1 parent cf0dcaf commit cae6781

4 files changed

Lines changed: 105 additions & 5 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ version = "0.1.0"
88
description = "Research-only political event and public disclosure tracking for US equities."
99
readme = "README.md"
1010
requires-python = ">=3.11"
11-
dependencies = []
11+
dependencies = [
12+
"defusedxml==0.7.1",
13+
]
1214
license = { text = "MIT" }
1315

1416
[project.optional-dependencies]

src/political_event_tracking_research/rss_source_fetch.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,21 @@
88
import json
99
import re
1010
import urllib.request
11-
import xml.etree.ElementTree as ET
1211
from collections.abc import Callable
1312
from dataclasses import dataclass
1413
from pathlib import Path
1514

15+
import defusedxml.ElementTree as ET
16+
from defusedxml.common import DefusedXmlException
17+
1618
from .csv_utils import read_csv_rows, write_csv_rows
1719

1820

1921
USER_AGENT = (
2022
"Mozilla/5.0 (compatible; QuantStrategyLabSourceIngest/0.1; "
2123
"+https://github.com/QuantStrategyLab/PoliticalEventTrackingResearch)"
2224
)
25+
MAX_XML_BYTES = 1024 * 1024
2326

2427

2528
@dataclass(frozen=True)
@@ -48,6 +51,10 @@ def to_json(self) -> dict[str, object]:
4851
}
4952

5053

54+
class FeedXmlError(ValueError):
55+
"""Sanitized producer-boundary XML failure."""
56+
57+
5158
def load_feed_config(path: str | Path) -> list[FeedConfig]:
5259
feeds: list[FeedConfig] = []
5360
for row in read_csv_rows(path):
@@ -71,7 +78,10 @@ def fetch_url(url: str) -> bytes:
7178
},
7279
)
7380
with urllib.request.urlopen(request, timeout=20) as response:
74-
return response.read()
81+
payload = response.read(MAX_XML_BYTES + 1)
82+
if len(payload) > MAX_XML_BYTES:
83+
raise FeedXmlError("feed_xml_oversize")
84+
return payload
7585

7686

7787
def strip_html(value: str) -> str:
@@ -122,7 +132,14 @@ def stable_item_id(feed_id: str, link: str, title: str) -> str:
122132

123133

124134
def parse_feed_items(feed_bytes: bytes, feed: FeedConfig, *, max_items: int = 25) -> list[dict[str, str]]:
125-
root = ET.fromstring(feed_bytes)
135+
if type(feed_bytes) is not bytes:
136+
raise FeedXmlError("feed_xml_invalid")
137+
if len(feed_bytes) > MAX_XML_BYTES:
138+
raise FeedXmlError("feed_xml_oversize")
139+
try:
140+
root = ET.fromstring(feed_bytes, forbid_dtd=True, forbid_entities=True, forbid_external=True)
141+
except (DefusedXmlException, ET.ParseError, LookupError, UnicodeError, ValueError, RecursionError):
142+
raise FeedXmlError("feed_xml_invalid") from None
126143
rows: list[dict[str, str]] = []
127144

128145
rss_items = root.findall("./channel/item")

tests/test_rss_source_fetch.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
import json
44
from pathlib import Path
5+
from unittest.mock import patch
56

67
import pytest
78

9+
from political_event_tracking_research import rss_source_fetch
810
from political_event_tracking_research.rss_source_fetch import FeedConfig, fetch_rss_sources, parse_feed_items
911

1012

@@ -68,6 +70,70 @@ def test_parse_atom_feed_items_to_source_items() -> None:
6870
assert "EVT2" in rows[0]["text"]
6971

7072

73+
@pytest.mark.parametrize(
74+
"payload",
75+
[
76+
b"<!DOCTYPE rss [<!ENTITY x SYSTEM 'file:///etc/passwd'>]><rss version='2.0'><channel/></rss>",
77+
b"<?xml version='1.0'?><!DOCTYPE rss [<!ENTITY laugh 'x'>]><rss version='2.0'><channel/></rss>",
78+
b"<?xml version='1.0' encoding='UTF-16'?><!DOCTYPE rss><rss version='2.0'><channel/></rss>",
79+
b"<?xml version='1.0' encoding='x-unknown'?><rss version='2.0'><channel/></rss>",
80+
],
81+
)
82+
def test_defused_parser_rejects_entities_and_dtd(payload: bytes) -> None:
83+
with pytest.raises(ValueError, match="feed_xml_"):
84+
parse_feed_items(payload, FeedConfig("x", "https://example.test", "official", ""))
85+
86+
87+
def test_utf16_entity_is_rejected_before_row_conversion() -> None:
88+
payload = """<?xml version='1.0' encoding='UTF-16'?>
89+
<!DOCTYPE rss [<!ENTITY x SYSTEM 'file:///etc/passwd'>]>
90+
<rss version='2.0'><channel><item><title>&x;</title></item></channel></rss>""".encode("utf-16")
91+
with pytest.raises(ValueError, match="feed_xml_"):
92+
parse_feed_items(payload, FeedConfig("x", "https://example.test", "official", ""))
93+
94+
95+
def test_utf16_valid_feed_is_supported_without_fallback() -> None:
96+
payload = """<?xml version='1.0' encoding='UTF-16'?>
97+
<rss version='2.0'><channel><item><title>UTF16</title>
98+
<link>https://example.test/utf16</link><pubDate>Fri, 01 May 2026 12:30:00 GMT</pubDate>
99+
</item></channel></rss>""".encode("utf-16")
100+
rows = parse_feed_items(payload, FeedConfig("x", "https://example.test", "official", ""))
101+
assert rows[0]["source_url"] == "https://example.test/utf16"
102+
103+
104+
def test_xml_payload_size_is_bounded_before_parse() -> None:
105+
feed = FeedConfig("x", "https://example.test", "official", "")
106+
with pytest.raises(ValueError, match="feed_xml_oversize"):
107+
parse_feed_items(b"x" * (rss_source_fetch.MAX_XML_BYTES + 1), feed)
108+
109+
110+
def test_network_read_is_bounded_and_oversize_is_sanitized() -> None:
111+
class Response:
112+
def __enter__(self):
113+
return self
114+
115+
def __exit__(self, *_args):
116+
return False
117+
118+
def read(self, size: int) -> bytes:
119+
assert size == rss_source_fetch.MAX_XML_BYTES + 1
120+
return b"x" * size
121+
122+
with patch.object(rss_source_fetch.urllib.request, "urlopen", return_value=Response()):
123+
with pytest.raises(ValueError, match="feed_xml_oversize"):
124+
rss_source_fetch.fetch_url("https://example.test/feed")
125+
126+
127+
def test_network_timeout_is_not_retried_or_parsed() -> None:
128+
with patch.object(rss_source_fetch.urllib.request, "urlopen", side_effect=TimeoutError("timeout")):
129+
with pytest.raises(TimeoutError, match="timeout"):
130+
rss_source_fetch.fetch_url("https://example.test/feed")
131+
132+
133+
def test_parser_has_no_stdlib_xml_fallback() -> None:
134+
assert rss_source_fetch.ET.__name__ == "defusedxml.ElementTree"
135+
136+
71137

72138
def test_fetch_rss_sources_can_continue_and_write_status(tmp_path: Path) -> None:
73139
feeds_path = tmp_path / "feeds.csv"

uv.lock

Lines changed: 16 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)