Skip to content

Commit 4752813

Browse files
authored
fix: fall back to plain text when RSS item content triggers RecursionError (#2333)
* fix: fall back to plain text when RSS item content triggers RecursionError Deeply nested HTML inside an RSS item's description or content:encoded previously hit the broad except in _parse_content and returned the raw unconverted HTML, silently embedding HTML tags in the markdown output. Catch RecursionError specifically and fall back to BeautifulSoup's iterative get_text(), mirroring the HTML converter fix from #1644. Includes a deterministic regression test. * Honor strict=true, similar to HTML
1 parent 76b965a commit 4752813

2 files changed

Lines changed: 125 additions & 11 deletions

File tree

‎packages/markitdown/src/markitdown/converters/_rss_converter.py‎

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import warnings
2+
13
from defusedxml import minidom
24
from xml.dom.minidom import Document, Element
35
from typing import BinaryIO, Any, Union
@@ -87,18 +89,23 @@ def convert(
8789
stream_info: StreamInfo,
8890
**kwargs: Any, # Options to pass to the converter
8991
) -> DocumentConverterResult:
92+
# Pop our own keyword before forwarding the rest to markdownify.
93+
# strict=True raises RecursionError instead of falling back to plain text.
94+
strict: bool = kwargs.pop("strict", False)
9095
self._kwargs = kwargs
9196
doc = minidom.parse(file_stream)
9297
feed_type = self._feed_type(doc)
9398

9499
if feed_type == "rss":
95-
return self._parse_rss_type(doc)
100+
return self._parse_rss_type(doc, strict=strict)
96101
elif feed_type == "atom":
97-
return self._parse_atom_type(doc)
102+
return self._parse_atom_type(doc, strict=strict)
98103
else:
99104
raise ValueError("Unknown feed type")
100105

101-
def _parse_atom_type(self, doc: Document) -> DocumentConverterResult:
106+
def _parse_atom_type(
107+
self, doc: Document, *, strict: bool = False
108+
) -> DocumentConverterResult:
102109
"""Parse the type of an Atom feed.
103110
104111
Returns None if the feed type is not recognized or something goes wrong.
@@ -121,16 +128,18 @@ def _parse_atom_type(self, doc: Document) -> DocumentConverterResult:
121128
if entry_updated:
122129
md_text += f"Updated on: {entry_updated}\n"
123130
if entry_summary:
124-
md_text += self._parse_content(entry_summary)
131+
md_text += self._parse_content(entry_summary, strict=strict)
125132
if entry_content:
126-
md_text += self._parse_content(entry_content)
133+
md_text += self._parse_content(entry_content, strict=strict)
127134

128135
return DocumentConverterResult(
129136
markdown=md_text,
130137
title=title,
131138
)
132139

133-
def _parse_rss_type(self, doc: Document) -> DocumentConverterResult:
140+
def _parse_rss_type(
141+
self, doc: Document, *, strict: bool = False
142+
) -> DocumentConverterResult:
134143
"""Parse the type of an RSS feed.
135144
136145
Returns None if the feed type is not recognized or something goes wrong.
@@ -158,21 +167,34 @@ def _parse_rss_type(self, doc: Document) -> DocumentConverterResult:
158167
if pubDate:
159168
md_text += f"Published on: {pubDate}\n"
160169
if description:
161-
md_text += self._parse_content(description)
170+
md_text += self._parse_content(description, strict=strict)
162171
if content:
163-
md_text += self._parse_content(content)
172+
md_text += self._parse_content(content, strict=strict)
164173

165174
return DocumentConverterResult(
166175
markdown=md_text,
167176
title=channel_title,
168177
)
169178

170-
def _parse_content(self, content: str) -> str:
179+
def _parse_content(self, content: str, *, strict: bool = False) -> str:
171180
"""Parse the content of an RSS feed item"""
172181
try:
173182
# using bs4 because many RSS feeds have HTML-styled content
174183
soup = BeautifulSoup(content, "html.parser")
175184
return _CustomMarkdownify(**self._kwargs).convert_soup(soup)
185+
except RecursionError:
186+
if strict:
187+
raise
188+
# Deeply nested item content can exceed Python's recursion limit
189+
# during markdownify's recursive DOM traversal. Fall back to
190+
# BeautifulSoup's iterative get_text() so the caller still gets
191+
# usable plain-text content instead of raw HTML.
192+
warnings.warn(
193+
"RSS item content is too deeply nested for markdown conversion "
194+
"(RecursionError). Falling back to plain-text extraction.",
195+
stacklevel=2,
196+
)
197+
return BeautifulSoup(content, "html.parser").get_text("\n", strip=True)
176198
except BaseException as _:
177199
return content
178200

‎packages/markitdown/tests/test_module_misc.py‎

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from markitdown._uri_utils import parse_data_uri, file_uri_to_path
1111
from markitdown._markitdown import _get_content_disposition_filename
12+
from markitdown.converters import RssConverter
1213

1314
from markitdown import (
1415
MarkItDown,
@@ -434,6 +435,7 @@ def test_deeply_nested_html_fallback() -> None:
434435
# Should have emitted a warning about the fallback
435436
recursion_warnings = [x for x in w if "deeply nested" in str(x.message)]
436437
assert len(recursion_warnings) > 0
438+
437439
finally:
438440
sys.setrecursionlimit(original_limit)
439441

@@ -444,6 +446,96 @@ def test_deeply_nested_html_fallback() -> None:
444446
assert "<p>" not in result.markdown
445447

446448

449+
def test_deeply_nested_rss_item_fallback() -> None:
450+
"""Deeply nested HTML inside an RSS item should fall back to plain-text
451+
extraction instead of silently embedding raw unconverted HTML in the
452+
markdown output (same failure class as the HTML converter fix in #1644).
453+
454+
Note: This test uses sys.setrecursionlimit to guarantee a RecursionError
455+
regardless of the host environment's default limit, making it deterministic
456+
across different platforms and CI configurations.
457+
"""
458+
import sys
459+
import warnings
460+
461+
markitdown = MarkItDown()
462+
463+
# Use a small recursion limit so the test is environment-independent.
464+
# We restore the original limit in a finally block to avoid side-effects.
465+
original_limit = sys.getrecursionlimit()
466+
low_limit = 200 # well below markdownify's traversal depth for depth=500
467+
468+
# Build an RSS item whose content is deeply nested HTML
469+
depth = 500
470+
item_html = ""
471+
for _ in range(depth):
472+
item_html += '<div style="margin-left:10px">'
473+
item_html += "<p>Deep feed content with <b>bold text</b></p>"
474+
for _ in range(depth):
475+
item_html += "</div>"
476+
477+
rss = (
478+
'<?xml version="1.0" encoding="UTF-8"?>'
479+
'<rss version="2.0" '
480+
'xmlns:content="http://purl.org/rss/1.0/modules/content/">'
481+
"<channel>"
482+
"<title>Test Feed</title>"
483+
"<description>A test feed</description>"
484+
"<item>"
485+
"<title>Deep Item</title>"
486+
f"<content:encoded><![CDATA[{item_html}]]></content:encoded>"
487+
"</item>"
488+
"</channel>"
489+
"</rss>"
490+
)
491+
atom = (
492+
'<?xml version="1.0" encoding="UTF-8"?>'
493+
'<feed xmlns="http://www.w3.org/2005/Atom">'
494+
"<title>Test Feed</title>"
495+
"<entry>"
496+
"<title>Deep Entry</title>"
497+
f'<content type="html"><![CDATA[{item_html}]]></content>'
498+
"</entry>"
499+
"</feed>"
500+
)
501+
502+
try:
503+
sys.setrecursionlimit(low_limit)
504+
with warnings.catch_warnings(record=True) as w:
505+
warnings.simplefilter("always")
506+
result = markitdown.convert_stream(
507+
io.BytesIO(rss.encode("utf-8")),
508+
file_extension=".rss",
509+
)
510+
511+
# Should have emitted a warning about the fallback
512+
recursion_warnings = [x for x in w if "deeply nested" in str(x.message)]
513+
assert len(recursion_warnings) > 0
514+
515+
# strict=True should expose the conversion failure rather than applying
516+
# the plain-text fallback.
517+
with pytest.raises(RecursionError):
518+
RssConverter().convert(
519+
io.BytesIO(rss.encode("utf-8")),
520+
StreamInfo(extension=".rss"),
521+
strict=True,
522+
)
523+
with pytest.raises(RecursionError):
524+
RssConverter().convert(
525+
io.BytesIO(atom.encode("utf-8")),
526+
StreamInfo(extension=".atom"),
527+
strict=True,
528+
)
529+
finally:
530+
sys.setrecursionlimit(original_limit)
531+
532+
# The output should contain the text content, not raw HTML
533+
assert "Deep feed content" in result.markdown
534+
assert "bold text" in result.markdown
535+
assert "<div" not in result.markdown
536+
assert "<p>" not in result.markdown
537+
538+
447539
def test_doc_rlink() -> None:
448540
# Test for: CVE-2025-11849
449541
markitdown = MarkItDown()
@@ -644,7 +736,7 @@ def test_json_with_late_non_ascii_character(tmp_path) -> None:
644736
"title": "Example record",
645737
"abstract": "This is sample test. " * 500,
646738
},
647-
"notes": "non-ASCII character: è",
739+
"notes": "non-ASCII character: è",
648740
}
649741
json_path = tmp_path / "input.json"
650742
json_path.write_text(
@@ -653,7 +745,7 @@ def test_json_with_late_non_ascii_character(tmp_path) -> None:
653745

654746
result = MarkItDown().convert(str(json_path))
655747

656-
assert "non-ASCII character: è" in result.text_content
748+
assert "non-ASCII character: è" in result.text_content
657749

658750

659751
if __name__ == "__main__":

0 commit comments

Comments
 (0)