mirror of
https://github.com/domainaware/parsedmarc.git
synced 2026-10-24 03:28:58 +00:00
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:
1 parent
1c335d951f
commit
14aa287dc1
7 files changed
+200
-8
No files matched your search
@@ -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"""
|
||||
|
||||
Reference in new issue
Block a user