mirror of
https://github.com/domainaware/parsedmarc.git
synced 2026-11-09 10:51:44 +00:00
Neutralize CSV formulas and escape control characters in logged strings (#931)
* Neutralize CSV formulas and escape control characters in logged strings CSV fields starting with =, +, -, @, tab, or CR get a leading apostrophe (OWASP CSV Injection, CWE-1236). Sender-controlled subject/From and a few aggregate-parser values are passed through the new sanitize_for_log() before they reach error messages and logs. Closes #929 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Sanitize more sender-controlled log values; fix sanitize_for_log docs Escape the SMTP TLS policy-type in its error and org_name/report_id in the duplicate-report debug lines, correct the sanitize_for_log docstring, and drop the unreachable return in parse_report_email(). Closes #929 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Test the From-header escaping with a raw control character mail-parser 4.8.0, which CI resolves, no longer decodes an RFC 2047 encoded word in the From header (4.6.2 did), so the previous test saw the encoded word rather than the CR LF it was meant to observe. A raw ESC in the display name survives both versions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
c1e7662e9e
commit
16a9016e2a
7 files changed
+284
-30
No files matched your search
@@ -1007,6 +1007,94 @@ class Test(unittest.TestCase):
|
||||
row = next(reader)
|
||||
self.assertEqual(row["organization_name"], "ExampleInc.")
|
||||
|
||||
def testCsvSafeNeutralizesFormulaPrefixes(self):
|
||||
"""_csv_safe prefixes a string starting with any spreadsheet
|
||||
formula trigger with ``'`` (CWE-1236; OWASP CSV Injection,
|
||||
https://owasp.org/www-community/attacks/CSV_Injection) and leaves
|
||||
everything else alone, including non-strings."""
|
||||
cases = [
|
||||
("=1+1", "'=1+1"),
|
||||
("+1", "'+1"),
|
||||
("-1", "'-1"),
|
||||
("@SUM(A1)", "'@SUM(A1)"),
|
||||
("\tcmd", "'\tcmd"),
|
||||
("\rcmd", "'\rcmd"),
|
||||
("\x00=1+1", "'=1+1"),
|
||||
("Example Inc.", "Example Inc."),
|
||||
("a-b=c+d@e", "a-b=c+d@e"),
|
||||
("", ""),
|
||||
(15169, 15169),
|
||||
(None, None),
|
||||
]
|
||||
for value, expected in cases:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(parsedmarc._csv_safe(value), expected)
|
||||
|
||||
def testAggregateCsvNeutralizesFormulaInOrgName(self):
|
||||
"""A reporter-supplied ``=HYPERLINK(...)`` org_name is written to
|
||||
the aggregate CSV with a leading apostrophe, while ordinary
|
||||
values and numeric columns are unchanged."""
|
||||
payload = '=HYPERLINK("http://evil","x")'
|
||||
escaped = payload.replace('"', """)
|
||||
xml = (
|
||||
"<feedback><report_metadata>"
|
||||
f"<org_name>{escaped}</org_name>"
|
||||
"<email>a@example.com</email><report_id>r-1</report_id>"
|
||||
"<date_range><begin>1529366400</begin><end>1529452799</end>"
|
||||
"</date_range></report_metadata><policy_published>"
|
||||
"<domain>example.com</domain><p>none</p></policy_published>"
|
||||
"<record><row><source_ip>192.0.2.1</source_ip><count>3</count>"
|
||||
"<policy_evaluated><disposition>none</disposition>"
|
||||
"<dkim>fail</dkim><spf>fail</spf></policy_evaluated></row>"
|
||||
"<identifiers><header_from>example.com</header_from>"
|
||||
"</identifiers><auth_results></auth_results></record></feedback>"
|
||||
)
|
||||
report = parsedmarc.parse_aggregate_report_xml(xml, offline=True)
|
||||
self.assertEqual(report["report_metadata"]["org_name"], payload)
|
||||
|
||||
csv_text = parsedmarc.parsed_aggregate_reports_to_csv(report)
|
||||
|
||||
row = next(csv.DictReader(StringIO(csv_text)))
|
||||
self.assertEqual(row["org_name"], "'" + payload)
|
||||
self.assertEqual(row["domain"], "example.com")
|
||||
self.assertEqual(row["count"], "3")
|
||||
|
||||
report["report_metadata"]["org_name"] = "Example-Org Inc."
|
||||
row = next(
|
||||
csv.DictReader(StringIO(parsedmarc.parsed_aggregate_reports_to_csv(report)))
|
||||
)
|
||||
self.assertEqual(row["org_name"], "Example-Org Inc.")
|
||||
|
||||
def testFailureReportCsvNeutralizesFormulaInSubject(self):
|
||||
"""A failure report sample subject starting with ``@`` is written
|
||||
with a leading apostrophe."""
|
||||
parsed_report = cast(
|
||||
FailureReport,
|
||||
parsedmarc.parse_report_file(
|
||||
"samples/failure/dmarc_ruf_report_linkedin.eml", offline=True
|
||||
)["report"],
|
||||
)
|
||||
parsed_report["parsed_sample"]["subject"] = "@evil"
|
||||
|
||||
csv_text = parsedmarc.parsed_failure_reports_to_csv(parsed_report)
|
||||
|
||||
row = next(csv.DictReader(StringIO(csv_text)))
|
||||
self.assertEqual(row["subject"], "'@evil")
|
||||
|
||||
def testSmtpTlsCsvNeutralizesFormulaInOrganizationName(self):
|
||||
"""An SMTP TLS report string field starting with ``+`` is written
|
||||
with a leading apostrophe."""
|
||||
result = parsedmarc.parse_report_file(
|
||||
"samples/smtp_tls/smtp_tls.json", offline=True
|
||||
)
|
||||
report = cast(SMTPTLSReport, result["report"])
|
||||
report["organization_name"] = "+1-555-0100"
|
||||
|
||||
csv_text = parsedmarc.parsed_smtp_tls_reports_to_csv(report)
|
||||
|
||||
row = next(csv.DictReader(StringIO(csv_text)))
|
||||
self.assertEqual(row["organization_name"], "'+1-555-0100")
|
||||
|
||||
def testAggregateCsvExposesASNColumns(self):
|
||||
"""The aggregate CSV output should include source_asn, source_as_name,
|
||||
and source_as_domain columns."""
|
||||
@@ -3094,6 +3182,77 @@ This is not a DMARC report."""
|
||||
with self.assertRaises(parsedmarc.InvalidDMARCReport):
|
||||
parsedmarc.parse_report_email(email_str, offline=True)
|
||||
|
||||
def testSubjectControlCharactersEscapedInErrorMessage(self):
|
||||
"""An RFC 2047 subject that decodes to CR LF is escaped in the
|
||||
error message, so a sender cannot forge extra log lines (the
|
||||
mailbox loops log this message at WARNING)."""
|
||||
email_str = (
|
||||
"From: test@example.com\n"
|
||||
"Subject: =?utf-8?q?line1=0D=0AINJECTED=20fake=20log?=\n"
|
||||
"Content-Type: text/plain\n\nNot a report."
|
||||
)
|
||||
with self.assertRaises(parsedmarc.InvalidDMARCReport) as ctx:
|
||||
parsedmarc.parse_report_email(email_str, offline=True)
|
||||
message = str(ctx.exception)
|
||||
self.assertIn("line1\\r\\nINJECTED fake log", message)
|
||||
self.assertNotIn("\r", message)
|
||||
self.assertNotIn("\n", message)
|
||||
|
||||
def testSmtpTlsPolicyTypeControlCharactersEscapedInError(self):
|
||||
"""A sender-supplied ``policy-type`` containing CR LF is escaped in
|
||||
the ``Invalid policy type`` error, which the mailbox loops log."""
|
||||
report = json.dumps(
|
||||
{
|
||||
"organization-name": "Example",
|
||||
"date-range": {
|
||||
"start-datetime": "2016-04-01T00:00:00Z",
|
||||
"end-datetime": "2016-04-01T23:59:59Z",
|
||||
},
|
||||
"contact-info": "tls@example.com",
|
||||
"report-id": "r-1",
|
||||
"policies": [
|
||||
{
|
||||
"policy": {
|
||||
"policy-type": "sts\r\nWARNING:cli.py:1:INJECTED",
|
||||
"policy-domain": "example.com",
|
||||
},
|
||||
"summary": {
|
||||
"total-successful-session-count": 1,
|
||||
"total-failure-session-count": 0,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
with self.assertRaises(parsedmarc.InvalidSMTPTLSReport) as ctx:
|
||||
parsedmarc.parse_smtp_tls_report_json(report)
|
||||
message = str(ctx.exception)
|
||||
self.assertIn("sts\\r\\nWARNING:cli.py:1:INJECTED", message)
|
||||
self.assertNotIn("\r", message)
|
||||
self.assertNotIn("\n", message)
|
||||
|
||||
def testFromHeaderControlCharactersEscapedInInfoLog(self):
|
||||
"""The ``Parsing mail from`` info line escapes control characters
|
||||
in the From header.
|
||||
|
||||
The control character is a raw ESC in the display name rather than
|
||||
an RFC 2047 encoded word: mail-parser 4.6.2 decodes encoded words
|
||||
in ``From`` but 4.8.0 (what CI resolves) leaves them encoded, while
|
||||
both keep a raw control character, so the test observes the
|
||||
escaping regardless of which mail-parser is installed."""
|
||||
email_str = (
|
||||
'From: "a\x1bFORGED" <test@example.com>\n'
|
||||
"Subject: Hello\n"
|
||||
"Content-Type: text/plain\n\nNot a report."
|
||||
)
|
||||
with self.assertLogs("parsedmarc.log", level="INFO") as logs:
|
||||
with self.assertRaises(parsedmarc.InvalidDMARCReport):
|
||||
parsedmarc.parse_report_email(email_str, offline=True)
|
||||
line = next(m for m in logs.output if "Parsing mail from" in m)
|
||||
self.assertIn("\\x1bFORGED", line)
|
||||
self.assertNotIn("\x1b", line)
|
||||
self.assertEqual(len(line.splitlines()), 1)
|
||||
|
||||
def testUnparseableDateRaisesParserError(self):
|
||||
"""An unparseable Date header trips the initial mail-parse catch-all"""
|
||||
# human_timestamp_to_datetime() raises on a junk Date, which the
|
||||
|
||||
Reference in new issue
Block a user