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:
Sean WhalenandClaude Fable 5.1 authored and GitHub committed 2026-10-26 21:34:23 -04:00
1 parent c1e7662e9e
commit 16a9016e2a
7 files changed
+284 -30

No files matched your search

+159
View File
@@ -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('"', "&quot;")
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