From 4d65c2de0a64a663de4458f5cfdc6c59dac31ec6 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 8 Sep 2026 15:26:32 +0200 Subject: fix: prefer the observed address over an attacker's HELO literal _extract_ip returned the FIRST bracketed IP in a Received header value. Postfix (and others) write the client's own HELO/EHLO argument first and the address it actually observed on the connection second: Received: from [198.51.100.7] (unknown [203.0.113.99]) by mx... The first bracket is entirely attacker-chosen; a client can HELO with any literal it likes. sending_ip() returned 198.51.100.7, reporting whoever the attacker named rather than 203.0.113.99, the address the accepting server itself wrote. This needs no forged extra hop, only a client that HELOs with an address literal, and the module's own docstring already stated the intended answer ("the bracketed literal after the connecting hostname") without the code implementing it. _extract_ip now collects every bracketed, ipaddress-valid literal with its position and, when there is more than one, prefers the last one appearing before " by " (the accepting server's own clause, and the one closest to it). A header with a single bracketed IP or no " by " token keeps the previous single-candidate behaviour, so simple.eml (203.0.113.42) and forged-chain.eml (203.0.113.99, item 3's own mutation-checked test) are unaffected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R --- tests/test_parse.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) (limited to 'tests') diff --git a/tests/test_parse.py b/tests/test_parse.py index a29b7a1..49fc41e 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -63,6 +63,28 @@ class TestReceivedChain(unittest.TestCase): ) self.assertIsNone(ip) + def test_a_helo_literal_does_not_beat_the_observed_address(self): + # Postfix writes the client's own HELO string first and the address + # it actually observed second. The first is attacker-chosen. + raw = ( + b"Received: from [198.51.100.7] (unknown [203.0.113.99])" + b" by mx.example.org with ESMTP id X;" + b" Tue, 8 Sep 2026 10:00:00 +0200\r\n" + b"From: \r\nSubject: t\r\n\r\nbody\r\n" + ) + self.assertEqual(parse.sending_ip(raw, trusted=["192.0.2.0/24"]), + "203.0.113.99") + + def test_a_single_bracketed_address_still_works(self): + raw = ( + b"Received: from x.invalid (x.invalid [203.0.113.5])" + b" by mx.example.org with ESMTP id Y;" + b" Tue, 8 Sep 2026 10:00:00 +0200\r\n" + b"From: \r\nSubject: t\r\n\r\nbody\r\n" + ) + self.assertEqual(parse.sending_ip(raw, trusted=["192.0.2.0/24"]), + "203.0.113.5") + class TestSenderDomains(unittest.TestCase): def test_the_three_sender_headers_are_collected(self): -- cgit v1.2.3