aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_report.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-09 19:48:22 +0200
committerDanilo M. <danix@danix.xyz>2026-09-09 19:48:22 +0200
commit57dc93c3069ccac583533f4f2d4f9cbc2720c73e (patch)
treecef3411c3fbb3d3289a3b35f9a3c55adf34cfebc /tests/test_report.py
parent0fd9f1632d25142323df419e189c46c8068694bf (diff)
downloadabusectl-master.tar.gz
abusectl-master.zip
feat: assemble the RFC 5965 report documentHEADmaster
Three parts and no message/rfc822: the original carries every identifier the first property keeps out, and text/rfc822-headers is the standard's own answer for a report that cannot include the message. The third part filters the manifest's headers against the same whitelist parse.report_headers() applies, rather than trusting it. The structural argument is that report cannot disclose what it was never given, and a manifest is a file the user edits: that is exactly a way it can be given a To. parse still decides; this refuses to publish what it did not decide for. A header value is attacker-supplied free text the spec keeps deliberately, so a newline in a Subject forges a header line in a part read entirely as headers. RFC 2047 turns the break into a fold instead, and a parser unfolds it back to one header with the value intact. Applied only when a value carries something unsafe, since encoding every header costs the desk the legibility this part exists for. From is built with headerregistry.Address: "Example Consulting, Ltd" through an f-string parses back as two addresses, the first a bogus addr-spec with no domain, so a desk's reply reaches nobody. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xj1ayFRSUQ2u7cwb3S4axE
Diffstat (limited to 'tests/test_report.py')
-rw-r--r--tests/test_report.py219
1 files changed, 219 insertions, 0 deletions
diff --git a/tests/test_report.py b/tests/test_report.py
index f414841..6a340c9 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -1,4 +1,6 @@
import copy
+import email
+import email.policy
import json
import unittest
@@ -1377,5 +1379,222 @@ class FeedbackInjection(unittest.TestCase):
self.assertEqual(lookup["Source"], "42")
+class Document(unittest.TestCase):
+ def setUp(self):
+ self.destination = report.email_destinations(MANIFEST["contacts"])[0]
+ self.raw = report.build(MANIFEST, self.destination, IDENTITY)
+ self.parsed = email.message_from_string(
+ self.raw, policy=email.policy.default
+ )
+
+ def test_it_is_a_feedback_report_with_three_parts(self):
+ self.assertEqual(self.parsed.get_content_type(), "multipart/report")
+ self.assertEqual(self.parsed.get_param("report-type"),
+ "feedback-report")
+ parts = list(self.parsed.iter_parts())
+ self.assertEqual(
+ [part.get_content_type() for part in parts],
+ ["text/plain", "message/feedback-report", "text/rfc822-headers"],
+ )
+
+ def test_the_envelope_is_addressed_and_identified(self):
+ self.assertEqual(self.parsed["To"], "abuse@host.invalid")
+ self.assertIn("reporter@example.org", self.parsed["From"])
+ self.assertTrue(self.parsed["Subject"])
+
+ def test_the_source_message_is_never_attached(self):
+ self.assertNotIn("message/rfc822", self.raw)
+
+ def test_the_headers_part_carries_what_the_manifest_declared(self):
+ """The third part is the whitelisted headers, unmangled.
+
+ Asserted by PARSING the part as headers rather than by looking for
+ substrings, because what a desk does with this part is parse it.
+ """
+ headers = self._headers_of(self.parsed)
+ self.assertEqual(headers.keys(), ["From", "Subject", "Date"])
+ self.assertEqual(headers["Subject"],
+ "Your account requires verification")
+ self.assertEqual(headers["Date"], "Mon, 07 Sep 2026 09:12:40 +0000")
+ # The From is asserted twice over: on the wire text, which is what
+ # is actually published, and on the address a desk would act on.
+ # A structured header re-renders the display name's quoting on
+ # read-back, so only the raw text pins what was written.
+ self.assertIn('From: "Example Bank" <phish@sender.invalid>', self.raw)
+ self.assertEqual([a.addr_spec for a in headers["From"].addresses],
+ ["phish@sender.invalid"])
+
+ @staticmethod
+ def _headers_of(parsed):
+ """Parse the third part's body the way a desk's parser would."""
+ body = list(parsed.iter_parts())[2].get_content()
+ return email.message_from_string(body, policy=email.policy.default)
+
+
+class DocumentHeaders(unittest.TestCase):
+ """The third part: what a hand-edited manifest can and cannot publish."""
+
+ def _build(self, headers):
+ manifest = copy.deepcopy(MANIFEST)
+ manifest["headers"] = headers
+ destination = report.email_destinations(manifest["contacts"])[0]
+ return report.build(manifest, destination, IDENTITY)
+
+ def _part(self, raw, index=2):
+ parsed = email.message_from_string(raw, policy=email.policy.default)
+ return list(parsed.iter_parts())[index]
+
+ def test_a_recipient_header_in_the_manifest_is_not_published(self):
+ """A manifest is a FILE THE USER EDITS, so it can carry a "To".
+
+ parse.report_headers() would never produce one, which is exactly
+ why asserting on its output proves nothing: the property has to
+ survive a manifest nobody generated. The victim of getting this
+ wrong is the recipient, whose address reaches the abuse desk and
+ through it the attacker.
+ """
+ raw = self._build([
+ ("To", "victim@example.org"),
+ ("Cc", "other@example.org"),
+ ("Delivered-To", "victim@example.org"),
+ ("X-Original-To", "victim@example.org"),
+ ("Subject", "kept"),
+ ])
+ body = self._part(raw).get_content()
+ self.assertNotIn("victim", raw)
+ self.assertNotIn("other@example.org", raw)
+ published = email.message_from_string(body,
+ policy=email.policy.default)
+ self.assertEqual(published.keys(), ["Subject"])
+
+ def test_a_newline_in_a_value_cannot_forge_a_header(self):
+ """Subject is attacker-controlled free text and is kept deliberately.
+
+ Emitted verbatim it forges a header line in a part whose entire
+ content is read as headers. The value must survive intact and the
+ header list must not grow.
+ """
+ raw = self._build([
+ ("Subject", "lure\nFrom: forged@attacker.invalid"),
+ ])
+ published = email.message_from_string(
+ self._part(raw).get_content(), policy=email.policy.default)
+ self.assertEqual(published.keys(), ["Subject"])
+ self.assertEqual(published["Subject"],
+ "lure From: forged@attacker.invalid")
+ self.assertEqual(published["From"], None)
+
+ def test_every_break_character_is_neutralised(self):
+ """Not only LF. Python's own parsers break on more than RFC 5322 does.
+
+ One header in, one header out, for each character in turn.
+ """
+ for ch in ("\r", "\n", "\r\n", "\v", "\f", "\x1c", "\x1d", "\x1e",
+ "\x85", "
", "
"):
+ with self.subTest(ch=repr(ch)):
+ raw = self._build([("Subject", f"a{ch}From: forged@x.invalid")])
+ published = email.message_from_string(
+ self._part(raw).get_content(),
+ policy=email.policy.default)
+ self.assertEqual(published.keys(), ["Subject"])
+ self.assertEqual(published["From"], None)
+
+ def test_an_ordinary_value_is_left_legible(self):
+ """RFC 2047 is applied only when it is needed.
+
+ Encoding every header would render an ordinary Subject as
+ "=?utf-8?q?..." and cost the desk the legibility this part is for.
+ """
+ raw = self._build([("Subject", "Your account requires verification")])
+ # Asserted on the THIRD PART's own text, not on the whole document.
+ # The text part prints the same header under "Message as declared",
+ # so a whole-document substring passes even when this part is
+ # entirely encoded, which a mutation confirmed.
+ body = self._part(raw).get_content()
+ self.assertEqual(body.splitlines(),
+ ["Subject: Your account requires verification"])
+ self.assertNotIn("=?utf-8?", body)
+
+ def test_a_case_with_no_headers_omits_the_part(self):
+ """An empty third part claims the message declared no headers.
+
+ That is never true of a real message. Absent says the report
+ carries none, which is the truth for a case parsed before the
+ headers block existed.
+ """
+ for headers in ([], None):
+ with self.subTest(headers=headers):
+ raw = self._build(headers)
+ parsed = email.message_from_string(
+ raw, policy=email.policy.default)
+ self.assertEqual(
+ [p.get_content_type() for p in parsed.iter_parts()],
+ ["text/plain", "message/feedback-report"])
+ self.assertNotIn("rfc822-headers", raw)
+
+ def test_a_manifest_carrying_only_unpublishable_headers_omits_the_part(
+ self):
+ """The filter must not leave an empty part behind either."""
+ raw = self._build([("To", "victim@example.org")])
+ parsed = email.message_from_string(raw, policy=email.policy.default)
+ self.assertEqual(
+ [p.get_content_type() for p in parsed.iter_parts()],
+ ["text/plain", "message/feedback-report"])
+ self.assertNotIn("victim", raw)
+
+ def test_a_header_name_is_matched_case_insensitively(self):
+ """A header name is case-insensitive, and a hand edit will not match.
+
+ Both directions matter: a lowercased "subject" must still publish,
+ and an uppercased "TO" must still be refused.
+ """
+ raw = self._build([("subject", "lower"), ("SUBJECT", "upper"),
+ ("Subject", "mixed"), ("TO", "victim@example.org")])
+ published = email.message_from_string(
+ self._part(raw).get_content(), policy=email.policy.default)
+ # All three spellings publish. The whitelist is stored lowercase, so
+ # a case-SENSITIVE comparison would still admit "subject" and the
+ # assertion would pass while the other two vanished; a mutation
+ # found exactly that. The capitalised spellings are what pin it.
+ self.assertEqual(published.keys(), ["subject", "SUBJECT", "Subject"])
+ self.assertNotIn("victim", raw)
+
+
+class DocumentIdentity(unittest.TestCase):
+ """The From header: the one identifier disclosed deliberately."""
+
+ def _from(self, identity):
+ destination = report.email_destinations(MANIFEST["contacts"])[0]
+ raw = report.build(MANIFEST, destination, identity)
+ parsed = email.message_from_string(raw, policy=email.policy.default)
+ return parsed["From"].addresses
+
+ def test_a_comma_in_the_org_name_does_not_split_the_address(self):
+ """"Example Consulting, Ltd" is a legitimate name, and a comma is
+ the address-list separator.
+
+ Formatted into an f-string it parses back as TWO addresses, the
+ first a bogus addr-spec with no domain, and a desk replying to the
+ report replies to nobody.
+ """
+ addresses = self._from({"name": "Example Consulting, Ltd",
+ "org": "Example Consulting",
+ "email": "reporter@example.org"})
+ self.assertEqual(len(addresses), 1)
+ self.assertEqual(addresses[0].addr_spec, "reporter@example.org")
+ self.assertEqual(addresses[0].display_name, "Example Consulting, Ltd")
+
+ def test_awkward_names_still_yield_one_reachable_address(self):
+ for name in ('A "Quoted" Reporter', "Angle <brackets>", "Dänilo Ü",
+ "Back\\slash", "semi;colon", "at@sign"):
+ with self.subTest(name=name):
+ addresses = self._from({"name": name, "org": "o",
+ "email": "reporter@example.org"})
+ self.assertEqual(len(addresses), 1)
+ self.assertEqual(addresses[0].addr_spec,
+ "reporter@example.org")
+ self.assertEqual(addresses[0].display_name, name)
+
+
if __name__ == "__main__":
unittest.main()