aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_parse.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_parse.py')
-rw-r--r--tests/test_parse.py136
1 files changed, 136 insertions, 0 deletions
diff --git a/tests/test_parse.py b/tests/test_parse.py
index 11345c7..6e3b7a4 100644
--- a/tests/test_parse.py
+++ b/tests/test_parse.py
@@ -290,5 +290,141 @@ class TestIocAssembly(unittest.TestCase):
self.assertNotIn("you%40example.org", blob)
+class ReportHeaders(unittest.TestCase):
+ def test_the_whitelist_keeps_what_a_desk_needs(self):
+ headers = parse.report_headers(load("reportable.eml"),
+ trusted=["192.0.2.0/24"])
+ names = [name for name, _ in headers]
+ for wanted in ("From", "Subject", "Date", "Message-ID", "Reply-To",
+ "Return-Path", "Authentication-Results", "Received-SPF"):
+ self.assertIn(wanted, names)
+
+ def test_recipient_headers_never_survive_the_whitelist(self):
+ # The first property, at the one place a report reproduces header
+ # text verbatim. A blacklist would have to remember each of these;
+ # the whitelist never names them at all.
+ headers = parse.report_headers(load("reportable.eml"),
+ trusted=["192.0.2.0/24"])
+ names = [name for name, _ in headers]
+ blob = repr(headers)
+ for name in ("To", "Cc", "Delivered-To", "X-Original-To"):
+ self.assertNotIn(name, names)
+ self.assertNotIn("victim@example.org", blob)
+ self.assertNotIn("colleague@example.org", blob)
+
+ def test_received_stops_at_the_boundary_hop(self):
+ # 192.0.2.10 is ours, so its Received line is our own infrastructure
+ # and must not be published; the hop below it is the one being
+ # reported and is kept.
+ headers = parse.report_headers(load("reportable.eml"),
+ trusted=["192.0.2.0/24"])
+ received = [value for name, value in headers if name == "Received"]
+ self.assertEqual(len(received), 1)
+ self.assertIn("203.0.113.42", received[0])
+ self.assertNotIn("mx.example.org with ESMTP id abc123", received[0])
+
+ def test_the_published_hop_carries_no_envelope_recipient(self):
+ # The boundary Received line is written by OUR OWN relay, and its
+ # optional "for <addr>" clause is the envelope recipient: the
+ # victim's address, verbatim, in the one header a report reproduces
+ # in full. Truncating the chain is not enough on its own.
+ headers = parse.report_headers(load("reportable.eml"),
+ trusted=["192.0.2.0/24"])
+ received = [value for name, value in headers if name == "Received"]
+ self.assertNotIn("you@example.org", received[0])
+ self.assertNotIn("for <", received[0])
+ # The rest of the hop survives; this is a cut, not a blanking.
+ self.assertIn("203.0.113.42", received[0])
+
+ def test_every_for_clause_shape_loses_the_address(self):
+ # RFC 5321 4.4 puts For inside Opt-info, so With, ID, Via or a CFWS
+ # comment may legitimately follow it, and its ABNF is
+ # 1*( Path / Mailbox ) where Mailbox carries no angle brackets.
+ # Anchoring on "for" being immediately followed by the clause
+ # terminator matched only the neatest shape and let four routine
+ # ones through, each publishing the victim's address.
+ hop = "from a.invalid (a.invalid [203.0.113.5]) by mx.example.org "
+ shapes = (
+ "for <you@example.org> (envelope-from <b@c.invalid>); Mon, 07 Sep 2026 09:12:40 +0000",
+ "for you@example.org; Mon, 07 Sep 2026 09:12:40 +0000",
+ "for <you@example.org> with ESMTP; Mon, 07 Sep 2026 09:12:40 +0000",
+ "id qq; Mon, 07 Sep 2026 09:12:40 +0000 (for <you@example.org>)",
+ "for <you@example.org>; Mon, 07 Sep 2026 09:12:40 +0000",
+ )
+ for tail in shapes:
+ with self.subTest(tail=tail):
+ stripped = parse._strip_envelope_recipient(hop + tail)
+ self.assertNotIn("you@example.org", stripped)
+ # The hop's own evidence survives: this is a cut, not a
+ # blanking, and a rule that ate the line would pass the
+ # assertion above while destroying the report.
+ self.assertIn("203.0.113.5", stripped)
+ self.assertIn("mx.example.org", stripped)
+
+ def test_stripping_leaves_no_doubled_space_or_stray_separator(self):
+ # Cosmetic in isolation, but the result is published verbatim to a
+ # third party, so a mangled line reads as a broken tool.
+ hop = ("from a.invalid (a.invalid [203.0.113.5]) by mx.example.org"
+ " for <you@example.org>; Mon, 07 Sep 2026 09:12:40 +0000")
+ stripped = parse._strip_envelope_recipient(hop)
+ self.assertNotIn(" ", stripped)
+ self.assertNotIn(" ;", stripped)
+ self.assertIn("mx.example.org; Mon", stripped)
+
+ def test_a_comment_holding_only_the_clause_leaves_no_debris(self):
+ hop = ("from a.invalid (a.invalid [203.0.113.5]) by mx.example.org"
+ " id qq; Mon, 07 Sep 2026 09:12:40 +0000 (for <you@example.org>)")
+ stripped = parse._strip_envelope_recipient(hop)
+ self.assertNotIn("you@example.org", stripped)
+ self.assertFalse(stripped.endswith("("))
+ self.assertTrue(stripped.endswith("+0000"))
+
+ def test_the_envelope_sender_comment_survives_the_cut(self):
+ # envelope-from is the SENDER, which is what the report is about, so
+ # cutting the recipient must not take it along.
+ hop = ("from a.invalid (a.invalid [203.0.113.5]) by mx.example.org"
+ " for <you@example.org> (envelope-from <bounce@sender.invalid>);"
+ " Mon, 07 Sep 2026 09:12:40 +0000")
+ stripped = parse._strip_envelope_recipient(hop)
+ self.assertNotIn("you@example.org", stripped)
+ self.assertIn("bounce@sender.invalid", stripped)
+
+ def test_the_whitelist_does_not_filter_attacker_free_text(self):
+ # A DOCUMENTED LIMIT, not a guarantee. The spec keeps Subject and the
+ # From display name knowing both are attacker-controlled free text,
+ # because they are what lets a desk recognise a campaign. An attacker
+ # who writes the recipient's own address into one, obfuscated or not,
+ # gets it published: the whitelist governs WHICH headers travel, never
+ # what is inside one.
+ #
+ # This is asserted so the limit is visible and deliberate. Do not
+ # "fix" it by filtering free text, which is the judgement-shaped
+ # problem AGENTS.md names as the source of every leak here. The
+ # sweep over real mail is what covers this class, per AGENTS.md.
+ raw = (b"Received: from a.invalid (a.invalid [203.0.113.5])"
+ b" by mx.example.org with ESMTP id X;"
+ b" Mon, 07 Sep 2026 09:12:40 +0000\r\n"
+ b"From: <phish@sender.invalid>\r\n"
+ b"Subject: Verify you%40example.org\r\n\r\nbody\r\n")
+ headers = parse.report_headers(raw, trusted=["192.0.2.0/24"])
+ subject = dict(headers)["Subject"]
+ self.assertIn("you%40example.org", subject)
+
+ def test_a_forged_chain_publishes_no_hop_below_the_boundary(self):
+ # The same job test_a_forged_chain_stops_at_the_first_untrusted_hop
+ # does for sending_ip(), asserted over what actually gets published:
+ # 198.51.100.7 is an innocent party the attacker named.
+ headers = parse.report_headers(load("forged-chain.eml"),
+ trusted=["192.0.2.0/24"])
+ received = [value for name, value in headers if name == "Received"]
+ # Asserted in BOTH directions: dropping Received altogether would
+ # satisfy the "not published" half on its own, and a test that
+ # passes when the feature is missing protects nothing.
+ self.assertEqual(len(received), 1)
+ self.assertIn("203.0.113.99", received[0])
+ self.assertTrue(all("198.51.100.7" not in value for value in received))
+ self.assertTrue(all("198.51.100.8" not in value for value in received))
+
+
if __name__ == "__main__":
unittest.main()