Fix ReDoS in failure-report, schema-tag, and timestamp regexes (11.0.4) (#921)

Three regular expressions that run on attacker-controlled input
backtracked super-linearly (CWE-1333):

GHSA-7grx-mj6g-qxmc (affects 8.2.0 through 11.0.3, reported by
mjatin2007): text_report_regex, used by the Exim/cPanel plain-text
failure-report fallback, was \s*([a-zA-Z\s]+):\s(.+). The leading \s*
and the \s inside the class overlap and the pattern is unanchored, so a
whitespace run is retried from every offset and every split: cubic,
about 8x per doubling (400 spaces 0.06 s, 1600 spaces 3.3 s on CPython
3.12). A ~100 KB message would stall the watch loop for days and be
re-parsed after every restart. The pattern is now anchored to a line
start with disjoint classes.

GHSA-4h9r-c435-j834 (affects 4.1.7 through 11.0.3, found while auditing
every regex in the package): xml_schema_regex was </??xs:schema.*>,
whose greedy .*> rescans to end of line for every <xs:schema
occurrence: quadratic, 1 MB in 19 s. It runs on every attachment that
decodes to "<" after extract_report() has inflated it up to the 100 MiB
cap, which 194 KB of gzip reaches. Now </?xs:schema[^<>]*>. This also
fixes the greedy match deleting a minified single-line report from the
schema tag to the last ">" on the line.

human_timestamp_to_datetime() stripped RFC 5322 comments with
\s*\(.*\)\s*, quadratic on whitespace (200,000 spaces 18.6 s) and
reached through every Date header; filed as a bug fix because MTAs cap
header size. Comments are now removed by a depth-counting scan, which is
linear and handles the nested comments and quoted-pairs RFC 5322
section 3.2.2 allows (a character-class regex would have regressed
them).

Adds regression tests sized so the old patterns fail the time bound in
minutes rather than hanging CI, a "Regular expressions on untrusted
input" section in AGENTS.md, and the 11.0.4 changelog and version bump.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sean WhalenandClaude Fable 5.1 authored and GitHub committed 2026-10-18 12:01:32 -04:00
1 parent 1c335d951f
commit 14aa287dc1
7 files changed
+200 -8

No files matched your search

+95
View File
@@ -16,6 +16,7 @@ import logging
import mailbox
import os
import quopri
import time
import unittest
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
@@ -2625,6 +2626,53 @@ class TestMalformedXmlRecovery(unittest.TestCase):
self.assertIn("missing required section", str(ctx.exception))
self.assertIsInstance(ctx.exception.__cause__, AttributeError)
def testXmlSchemaRegexRejectsRepeatedTagsQuickly(self):
"""A report made of repeated ``<xs:schema`` is rejected in linear
time.
Regression test for GHSA-4h9r-c435-j834: xml_schema_regex was
``</??xs:schema.*>``, whose unanchored greedy ``.*>`` rescanned to the
end of the line for every ``<xs:schema`` — quadratic, so 1 MB took
19 s and this 5 MB input would take minutes. The 10 s bound is not
flaky: the fixed pattern handles it in milliseconds, orders of
magnitude below both the bound and the old pattern's cost."""
start = time.perf_counter()
with self.assertRaises(parsedmarc.InvalidAggregateReport):
parsedmarc.parse_aggregate_report_xml(
"<xs:schema" * 500_000, offline=OFFLINE_MODE
)
self.assertLess(time.perf_counter() - start, 10)
def testMinifiedAggregateReportWithSchemaTag(self):
"""A report minified onto one line still parses when it carries an
``<xs:schema>`` tag.
The old greedy ``</??xs:schema.*>`` (GHSA-4h9r-c435-j834) deleted
from the schema tag to the last ``>`` on the line, which on a
single-line document discarded the whole report."""
path = "samples/aggregate/ikea.com!example.de!1538690400!1538776800.xml"
with open(path, encoding="utf-8") as f:
original = f.read()
minified = "".join(line.strip() for line in original.splitlines())
self.assertIn("<xs:schema", minified)
self.assertNotIn("\n", minified)
expected = parsedmarc.parse_aggregate_report_xml(
original, always_use_local_files=True, offline=OFFLINE_MODE
)
report = parsedmarc.parse_aggregate_report_xml(
minified, always_use_local_files=True, offline=OFFLINE_MODE
)
self.assertEqual(
report["report_metadata"]["org_name"],
expected["report_metadata"]["org_name"],
)
self.assertEqual(
report["report_metadata"]["report_id"],
expected["report_metadata"]["report_id"],
)
self.assertEqual(len(report["records"]), len(expected["records"]))
self.assertGreater(len(report["records"]), 0)
class TestPolicyPublishedEdgeCases(unittest.TestCase):
"""Tests for edge cases in policy_published parsing"""
@@ -3119,6 +3167,53 @@ This is not a DMARC report."""
parsedmarc.parse_report_email(eml, offline=True)
self.assertIn("Tls", str(ctx.exception))
def testTextReportRegexRejectsWhitespacePaddingQuickly(self):
"""A plain-text failure notice padded with whitespace is rejected in
linear time.
Regression test for GHSA-7grx-mj6g-qxmc: text_report_regex was
``\\s*([a-zA-Z\\s]+):\\s(.+)``, whose overlapping, unanchored
whitespace quantifiers backtracked cubically — 1600 spaces took
3.3 s, about 8x per doubling, so this 6,400-space body takes about
210 s: far over the bound, but short enough that a regression fails
instead of hanging the CI job. The 10 s bound is not flaky:
the fixed pattern parses it in milliseconds, orders of magnitude
below both the bound and the old pattern's cost."""
padding = " " * 6_400
body = "\n".join(padding[i : i + 70] for i in range(0, len(padding), 70))
msg = (
"From: mailer-daemon@example.com\n"
"To: dmarc@example.com\n"
"Subject: Failure notice\n"
"Date: Mon, 01 Jan 2024 00:00:00 +0000\n"
"Content-Type: text/plain\n\n"
"A message claiming to be from you has failed\n" + body + "\ndetected."
)
start = time.perf_counter()
# The required received-date field is missing, so the notice is
# rejected rather than parsed.
with self.assertRaises(parsedmarc.InvalidDMARCReport):
parsedmarc.parse_report_email(msg, offline=OFFLINE_MODE)
self.assertLess(time.perf_counter() - start, 10)
def testTextReportFieldNamesMustBeSingleLine(self):
"""text_report_regex matches Exim's one-line ``Name: value`` fields:
a field name cannot span a line break, leading indentation is
allowed, and a CRLF line ending is not kept in the value
(GHSA-7grx-mj6g-qxmc)."""
matches = parsedmarc.text_report_regex.findall("Sender\nDomain: x")
self.assertFalse(any("\n" in name for name, _ in matches))
self.assertEqual(
parsedmarc.text_report_regex.findall(" Sender IP Address: 1.2.3.4\n"),
[("Sender IP Address", "1.2.3.4")],
)
self.assertEqual(
parsedmarc.text_report_regex.findall(
"Received Date: Mon, 01 Jan 2024 00:00:00 +0000\r\n"
),
[("Received Date", "Mon, 01 Jan 2024 00:00:00 +0000")],
)
class TestFailureReportParsing(unittest.TestCase):
"""Tests for failure report field defaults and edge cases"""
+46
View File
@@ -249,6 +249,52 @@ class Test(unittest.TestCase):
"Mon, 01 Jan 2024 00:00:00 +0000 (UTC)"
)
self.assertEqual(dt.year, 2024)
# A tab before the comment, and more than one comment, are stripped
# too.
self.assertEqual(
parsedmarc.utils.human_timestamp_to_datetime(
"Tue, 1 Jan 2019 00:00:00 +0000\t(GMT)"
),
datetime(2019, 1, 1, tzinfo=timezone.utc),
)
self.assertEqual(
parsedmarc.utils.human_timestamp_to_datetime(
"Mon, 01 Jan 2024 00:00:00 +0000 (x) (y)"
),
datetime(2024, 1, 1, tzinfo=timezone.utc),
)
# RFC 5322 section 3.2.2 lets comments nest and contain quoted-pairs;
# a regex cannot strip those, which is why a depth-counting scan does.
self.assertEqual(
parsedmarc.utils.human_timestamp_to_datetime(
"Mon, 01 Jan 2024 00:00:00 +0000 (a (b) c)"
),
datetime(2024, 1, 1, tzinfo=timezone.utc),
)
self.assertEqual(
parsedmarc.utils.human_timestamp_to_datetime(
"Mon, 01 Jan 2024 00:00:00 +0000 (\\) x)"
),
datetime(2024, 1, 1, tzinfo=timezone.utc),
)
def testHumanTimestampToDatetimeWhitespaceIsLinear(self):
"""A whitespace-padded timestamp is rejected in linear time.
Comments were stripped with the regex ``\\s*\\(.*\\)\\s*``, whose
leading ``\\s*`` was retried from every position in a whitespace run
— quadratic, so 200,000 spaces took 18.6 s. The function runs on
every message's Date header. The 5 s bound is not flaky: the
depth-counting scan handles this input in milliseconds, orders of
magnitude below both the bound and the old pattern's cost. The
comment is left unclosed so the old pattern can never match: with a
closing parenthesis its first attempt succeeded and the backtracking
never happened."""
start = time.perf_counter()
# dateutil's ParserError subclasses ValueError.
with self.assertRaises(ValueError):
parsedmarc.utils.human_timestamp_to_datetime(" " * 200_000 + "(x")
self.assertLess(time.perf_counter() - start, 5)
def testHumanTimestampToDatetimeNegativeZero(self):
"""-0000 timezone is handled"""