diff --git a/changelog.d/20260720_023559_arpitjain099_bound_author_email.rst b/changelog.d/20260720_023559_arpitjain099_bound_author_email.rst new file mode 100644 index 00000000..ab3f0c9b --- /dev/null +++ b/changelog.d/20260720_023559_arpitjain099_bound_author_email.rst @@ -0,0 +1,7 @@ +Security +-------- + +* Bound the length of author values passed to the author-email regex. + The pattern backtracks quadratically, so a hostile feed with a very long + ```` or ``dc:creator`` value could make parsing consume seconds of + CPU. Long values now skip the email match instead. diff --git a/feedparser/mixin.py b/feedparser/mixin.py index 3acee1b1..8d48fb65 100644 --- a/feedparser/mixin.py +++ b/feedparser/mixin.py @@ -44,6 +44,13 @@ r"(\?subject=\S+)?" ) +# Feeds are untrusted input, and the domain part of ``email_pattern`` +# backtracks quadratically on long, almost-matching strings. Only run the +# search on values short enough to plausibly hold an address: RFC 5321 caps +# an address at 254 octets, and this leaves plenty of room for a display name +# alongside it. Anything longer is not a real author string, so skip it. +EMAIL_PATTERN_MAX_LENGTH = 1000 + class XMLParserMixin( _base.Namespace, @@ -796,7 +803,10 @@ def _sync_author_detail(self, key="author"): author, email = context.get(key), None if not author: return - emailmatch = email_pattern.search(author) + if len(author) <= EMAIL_PATTERN_MAX_LENGTH: + emailmatch = email_pattern.search(author) + else: + emailmatch = None if emailmatch: email = emailmatch.group(0) # probably a better way to do the following, but it passes diff --git a/tests/test_author_email_bound.py b/tests/test_author_email_bound.py new file mode 100644 index 00000000..6cace1bb --- /dev/null +++ b/tests/test_author_email_bound.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import feedparser +from feedparser.mixin import EMAIL_PATTERN_MAX_LENGTH + + +def _feed(author: str) -> str: + return ( + '' + 't' + f"x{author}" + "" + ) + + +def test_normal_author_email_still_extracted(): + result = feedparser.parse(_feed("Example editor (me@example.com)")) + detail = result.entries[0].author_detail + assert detail["name"] == "Example editor" + assert detail["email"] == "me@example.com" + + +def test_bare_email_still_extracted(): + result = feedparser.parse(_feed("me@example.com")) + assert result.entries[0].author_detail["email"] == "me@example.com" + + +def test_oversized_author_skips_email_match(): + # A hostile feed can supply an author value crafted so the email pattern + # backtracks quadratically. Values longer than the bound are left alone + # instead of being fed to the regex, so parsing stays fast. + hostile = "a@" + "a." * 16000 + "!" + assert len(hostile) > EMAIL_PATTERN_MAX_LENGTH + + result = feedparser.parse(_feed(hostile)) + + assert result.bozo is False + # The oversized value is kept verbatim as the name and no email is split + # out of it. + assert "email" not in result.entries[0].author_detail + assert result.entries[0].author_detail["name"] == hostile