From 96bd15309df4bb3a46051718ef30ae023f310b06 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 8 Sep 2026 13:34:43 +0200 Subject: feat: walk the Received chain to the trust boundary Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R --- abusectl/parse.py | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/test_parse.py | 68 +++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 abusectl/parse.py create mode 100644 tests/test_parse.py diff --git a/abusectl/parse.py b/abusectl/parse.py new file mode 100644 index 0000000..f243e0c --- /dev/null +++ b/abusectl/parse.py @@ -0,0 +1,114 @@ +# Copyright (C) 2026 Danilo M. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Walk a message's Received chain to find the reportable sending IP. + +This module is pure and offline. It opens no socket and reads no config: the +trust boundary (which relays are ours) arrives as an argument from the +caller, which keeps this testable with no setup and no environment. + +Received headers are prepended by each relay that handles a message, so the +list is newest-first: our own infrastructure at the top, the claimed +originator at the bottom. Everything below the point where the message +leaves our own infrastructure was written by whoever was talking to our +server and can be fabricated wholesale. The only defensible answer is the +first hop outside that boundary, walking from the top inward; anything +past it is an attacker's own words about who else to blame. + +Nothing in this module ever resolves a hostname or fetches a URL. That is a +safety property, not a performance choice: following a link or resolving an +address the sender controls confirms to them that the address is live and +can fire a tracker embedded in the DNS or HTTP response. +""" + +import ipaddress +import re +from dataclasses import dataclass +from email import policy +from email.parser import BytesParser + +_BRACKETED_IP = re.compile(r"\[([0-9a-fA-F:.]+)\]") + + +@dataclass(frozen=True) +class Hop: + ip: str + trusted: bool = False + + +class NoTrustBoundary(Exception): + """Raised when no trusted relays are configured. + + This is deliberately an error, not a warning with a best-effort guess. + Guessing the outermost public IP is wrong precisely in the case that + matters: an attacker forges Received headers naming innocent parties, + and a confident wrong answer gets an innocent party reported for abuse + they did not commit. Refusing to guess is the safe failure mode. + """ + + def __init__(self): + super().__init__("no trusted_relays configured: run `abusectl init`") + + +def _extract_ip(received_value: str) -> str | None: + # The bracketed literal after the connecting hostname is the only part + # of a Received header the accepting server itself wrote; everything + # else (the claimed hostname) is supplied by the connecting client and + # cannot be trusted. Validate through ipaddress so a stray bracketed + # token that isn't actually an IP is skipped rather than misreported. + for candidate in _BRACKETED_IP.findall(received_value): + try: + ipaddress.ip_address(candidate) + except ValueError: + continue + return candidate + return None + + +def received_hops(raw: bytes) -> list[Hop]: + """Return every Received hop that names an IP, outermost first.""" + message = BytesParser(policy=policy.default).parsebytes(raw) + hops = [] + for value in message.get_all("received") or []: + ip = _extract_ip(str(value)) + if ip is not None: + hops.append(Hop(ip=ip)) + return hops + + +def _in_any(ip: str, networks: list[str]) -> bool: + address = ipaddress.ip_address(ip) + return any( + address in ipaddress.ip_network(network, strict=False) + for network in networks + ) + + +def sending_ip(raw: bytes, trusted: list[str]) -> str | None: + """Return the first hop outside the trust boundary, or None. + + Walks the Received chain outermost first (the order received_hops + returns) and stops at the first IP not inside any network in `trusted`. + That is the point where the message left our own infrastructure; every + hop below it in the chain may have been written by the attacker. + + Returns None when every hop is inside the boundary, meaning the message + never left our own infrastructure as far as this chain shows. + """ + if not trusted: + raise NoTrustBoundary() + for hop in received_hops(raw): + if not _in_any(hop.ip, trusted): + return hop.ip + return None diff --git a/tests/test_parse.py b/tests/test_parse.py new file mode 100644 index 0000000..92095fd --- /dev/null +++ b/tests/test_parse.py @@ -0,0 +1,68 @@ +# Copyright (C) 2026 Danilo M. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Tests for the Received-chain trust boundary walk.""" + +import pathlib +import unittest + +from abusectl import parse + +FIXTURES = pathlib.Path(__file__).parent / "fixtures" + + +def load(name: str) -> bytes: + return (FIXTURES / name).read_bytes() + + +class TestReceivedChain(unittest.TestCase): + def test_hops_are_returned_outermost_first(self): + hops = parse.received_hops(load("simple.eml")) + self.assertEqual([h.ip for h in hops], ["192.0.2.11", "203.0.113.42"]) + + def test_the_first_untrusted_hop_is_the_sender(self): + ip = parse.sending_ip(load("simple.eml"), trusted=["192.0.2.0/24"]) + self.assertEqual(ip, "203.0.113.42") + + def test_a_forged_chain_stops_at_the_first_untrusted_hop(self): + # The attacker prepended two hops naming an innocent third party. + # Walking past the boundary would report 198.51.100.7, which is + # someone else's address in a header the attacker wrote. + ip = parse.sending_ip(load("forged-chain.eml"), trusted=["192.0.2.0/24"]) + self.assertEqual(ip, "203.0.113.99") + + def test_hops_below_the_boundary_are_still_recorded(self): + # Recorded, but as untrusted: they may be useful and must not be + # presented as fact. + hops = parse.received_hops(load("forged-chain.eml")) + self.assertEqual( + [h.ip for h in hops], + ["192.0.2.11", "203.0.113.99", "198.51.100.7", "198.51.100.8"], + ) + + def test_no_trusted_relays_is_an_error_not_a_guess(self): + # Guessing the outermost public IP is wrong in exactly the case that + # matters, and a confident wrong answer gets a third party reported. + with self.assertRaises(parse.NoTrustBoundary): + parse.sending_ip(load("simple.eml"), trusted=[]) + + def test_a_chain_entirely_inside_the_boundary_has_no_sender(self): + ip = parse.sending_ip( + load("simple.eml"), trusted=["192.0.2.0/24", "203.0.113.0/24"] + ) + self.assertIsNone(ip) + + +if __name__ == "__main__": + unittest.main() -- cgit v1.2.3