From 46e042a33bdd529da71115649d1736e703b676c8 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 9 Sep 2026 09:19:27 +0200 Subject: feat: read abuse addresses from a jCard, strictly Only an entity whose roles contain abuse counts. No fallback to a technical or registrant contact, who is a named human that never volunteered for abuse mail, and no fallback to abuse@ by convention: for a phishing domain that mailbox belongs to the attacker, so constructing it would confirm both the catch and that the reporter's address is live. Addresses are validated where they enter rather than where they are used, because the value becomes a mail recipient later and a control character in it is header injection into mail this tool sends. Entity recursion is depth capped so remote JSON cannot hang the tool. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz --- abusectl/rdap.py | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/test_rdap.py | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/abusectl/rdap.py b/abusectl/rdap.py index fc9cb12..94a5d02 100644 --- a/abusectl/rdap.py +++ b/abusectl/rdap.py @@ -181,3 +181,90 @@ def server_for_tld(tld: str, bootstrap_data: dict) -> str | None: if any(name.lower() == wanted for name in names): return urls[0] return None + + +import email.utils + +_MAX_ENTITY_DEPTH = 4 + + +def _valid_address(raw: str) -> str | None: + """Return a usable address, or None. + + This value becomes a mail recipient in report and submit, so it is + validated where it enters rather than where it is used: a control + character here is header injection into mail this tool sends. + """ + if not isinstance(raw, str) or not raw.strip(): + return None + if any(character in raw for character in "\r\n\t"): + return None + if any(ord(character) < 32 for character in raw): + return None + + name, address = email.utils.parseaddr(raw) + if not address or address.count("@") != 1: + return None + local, _, domain = address.partition("@") + if not local or not domain or "." not in domain: + return None + return address + + +def _emails_from_vcard(entity: dict) -> list[str]: + """Pull every email property value out of a jCard. + + jCard encodes vCard as nested arrays: vcardArray[1] is a list of + [name, params, type, value] properties, so the value is at index 3. + """ + found = [] + vcard = entity.get("vcardArray") + if not isinstance(vcard, list) or len(vcard) < 2: + return found + for prop in vcard[1]: + if not isinstance(prop, list) or len(prop) < 4: + continue + if prop[0] != "email": + continue + address = _valid_address(prop[3]) + if address: + found.append(address) + return found + + +def abuse_addresses(response: dict) -> list[str]: + """Return every published abuse address in an RDAP response. + + STRICT: only an entity whose roles contain "abuse" counts. There is no + fallback to a technical or registrant contact, because that is a named + human who never volunteered for abuse mail, and no fallback to + abuse@ by convention, because for a phishing domain that + mailbox belongs to the ATTACKER and mailing it would confirm both the + catch and that the user's address is live. + + A links referral is never followed. If the address is not in this + response, there is no address: following a URL the response chose for + us is an outbound fetch under remote control. + """ + found: list[str] = [] + + def walk(entities, depth): + if depth > _MAX_ENTITY_DEPTH or not isinstance(entities, list): + return + for entity in entities: + if not isinstance(entity, dict): + continue + roles = entity.get("roles") or [] + if isinstance(roles, list) and "abuse" in roles: + found.extend(_emails_from_vcard(entity)) + walk(entity.get("entities"), depth + 1) + + walk(response.get("entities"), 1) + + seen = set() + unique = [] + for address in found: + if address not in seen: + seen.add(address) + unique.append(address) + return unique diff --git a/tests/test_rdap.py b/tests/test_rdap.py index 14a1fd4..b7baa21 100644 --- a/tests/test_rdap.py +++ b/tests/test_rdap.py @@ -176,5 +176,90 @@ class ServerSelection(unittest.TestCase): self.assertIsNone(rdap.server_for_tld("example", self.DNS)) +def _entity(roles, emails, entities=None): + """Build an RDAP entity in real jCard shape.""" + properties = [["version", {}, "text", "4.0"]] + for address in emails: + properties.append(["email", {}, "text", address]) + entity = {"roles": roles, "vcardArray": ["vcard", properties]} + if entities: + entity["entities"] = entities + return entity + + +class AbuseExtraction(unittest.TestCase): + def test_an_abuse_entity_yields_its_address(self): + response = {"entities": [_entity(["abuse"], ["abuse@example.invalid"])]} + self.assertEqual( + rdap.abuse_addresses(response), ["abuse@example.invalid"] + ) + + def test_a_nested_abuse_entity_is_found(self): + """The abuse entity is usually a child of the organisation entity.""" + response = { + "entities": [ + _entity( + ["registrant"], [], + entities=[_entity(["abuse"], ["abuse@example.invalid"])], + ) + ] + } + self.assertEqual( + rdap.abuse_addresses(response), ["abuse@example.invalid"] + ) + + def test_a_technical_only_response_yields_nothing(self): + """A technical contact is a named human who never volunteered to + receive abuse mail. Mailing them is useless and is a small privacy + harm to an uninvolved third party.""" + response = {"entities": [_entity(["technical"], ["someone@example.invalid"])]} + self.assertEqual(rdap.abuse_addresses(response), []) + + def test_every_abuse_address_is_kept(self): + """Some netblocks publish two desks, and picking one arbitrarily + can drop the one that would have answered.""" + response = { + "entities": [ + _entity(["abuse"], ["one@example.invalid", "two@example.invalid"]) + ] + } + self.assertEqual( + rdap.abuse_addresses(response), + ["one@example.invalid", "two@example.invalid"], + ) + + def test_a_newline_in_an_address_is_rejected(self): + """The address becomes a mail recipient in report and submit, so a + CRLF here is header injection into mail this tool sends.""" + response = { + "entities": [ + _entity(["abuse"], ["abuse@example.invalid\r\nBcc: victim@example.org"]) + ] + } + self.assertEqual(rdap.abuse_addresses(response), []) + + def test_a_non_address_is_rejected(self): + response = {"entities": [_entity(["abuse"], ["not an address"])]} + self.assertEqual(rdap.abuse_addresses(response), []) + + def test_recursion_is_depth_capped(self): + """Remote JSON must not be able to hang the tool.""" + deep = _entity(["abuse"], ["deep@example.invalid"]) + for _ in range(10): + deep = _entity(["registrant"], [], entities=[deep]) + self.assertEqual(rdap.abuse_addresses({"entities": [deep]}), []) + + def test_duplicate_addresses_collapse(self): + response = { + "entities": [ + _entity(["abuse"], ["abuse@example.invalid"]), + _entity(["abuse"], ["abuse@example.invalid"]), + ] + } + self.assertEqual( + rdap.abuse_addresses(response), ["abuse@example.invalid"] + ) + + if __name__ == "__main__": unittest.main() -- cgit v1.2.3