aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--abusectl/rdap.py87
-rw-r--r--tests/test_rdap.py85
2 files changed, 172 insertions, 0 deletions
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@<domain> 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()