# 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 RDAP protocol module.""" import email import unittest import urllib.error import urllib.request from abusectl import rdap class RedirectPolicy(unittest.TestCase): """A redirect is remote data directing our next request. urllib.request.Request is used rather than a hand-rolled fake, because HTTPRedirectHandler reads attributes (origin_req_host, unverifiable, timeout) that a fake would have to reproduce exactly to prove anything. """ def _request(self): return urllib.request.Request("https://rdap.example.invalid/ip/192.0.2.1") def test_an_https_to_http_downgrade_is_refused(self): handler = rdap._NoDowngradeRedirectHandler() with self.assertRaises(urllib.error.HTTPError): handler.redirect_request( self._request(), None, 302, "Found", email.message_from_string(""), "http://rdap.example.invalid/ip/192.0.2.1", ) def test_an_https_to_https_redirect_is_allowed(self): handler = rdap._NoDowngradeRedirectHandler() result = handler.redirect_request( self._request(), None, 302, "Found", email.message_from_string(""), "https://other.example.invalid/ip/192.0.2.1", ) self.assertIsNotNone(result) import json import tempfile import time from pathlib import Path class Bootstrap(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.cache = Path(self.tmp.name) self.addCleanup(self.tmp.cleanup) def test_a_missing_file_is_fetched_and_cached(self): calls = [] def fetch(url): calls.append(url) return {"services": []} data = rdap.bootstrap("ipv4", cache_root=self.cache, fetch=fetch) self.assertEqual(data, {"services": []}) self.assertEqual(calls, ["https://data.iana.org/rdap/ipv4.json"]) self.assertTrue((self.cache / "ipv4.json").exists()) def test_a_fresh_cache_is_not_refetched(self): (self.cache / "ipv4.json").write_text(json.dumps({"services": ["cached"]})) def fetch(url): raise AssertionError(f"should not have fetched {url}") data = rdap.bootstrap("ipv4", cache_root=self.cache, fetch=fetch) self.assertEqual(data, {"services": ["cached"]}) def test_a_stale_cache_is_refetched(self): path = self.cache / "ipv4.json" path.write_text(json.dumps({"services": ["old"]})) old = time.time() - (rdap._BOOTSTRAP_TTL + 60) import os os.utime(path, (old, old)) data = rdap.bootstrap( "ipv4", cache_root=self.cache, fetch=lambda url: {"services": ["new"]} ) self.assertEqual(data, {"services": ["new"]}) def test_a_failed_refetch_falls_back_to_the_stale_copy(self): """Losing IANA must not stop the user filing a report. Last week's map is almost certainly still correct, and a stale bootstrap fails safe: the worst case is querying a server that has moved, which misses and reads as no contact. """ path = self.cache / "ipv4.json" path.write_text(json.dumps({"services": ["old"]})) old = time.time() - (rdap._BOOTSTRAP_TTL + 60) import os os.utime(path, (old, old)) def fetch(url): raise OSError("network is unreachable") data = rdap.bootstrap("ipv4", cache_root=self.cache, fetch=fetch) self.assertEqual(data, {"services": ["old"]}) def test_a_failed_fetch_with_no_cache_raises(self): def fetch(url): raise OSError("network is unreachable") with self.assertRaises(rdap.BootstrapUnavailable): rdap.bootstrap("ipv4", cache_root=self.cache, fetch=fetch) class ServerSelection(unittest.TestCase): IPV4 = { "services": [ [["192.0.2.0/24"], ["https://wide.example.invalid/"]], [["192.0.2.128/25"], ["https://narrow.example.invalid/"]], [["198.51.100.0/24"], ["https://other.example.invalid/"]], ] } DNS = { "services": [ [["invalid"], ["https://registry.example.invalid/"]], [["test"], ["https://test.example.invalid/"]], ] } def test_an_address_selects_its_range(self): self.assertEqual( rdap.server_for_ip("198.51.100.7", self.IPV4), "https://other.example.invalid/", ) def test_the_longest_prefix_wins(self): """192.0.2.200 is in both /24 and /25; the /25 is more specific. Choosing the wider range would ask a registry that has delegated the block away, and its answer would name the wrong operator. """ self.assertEqual( rdap.server_for_ip("192.0.2.200", self.IPV4), "https://narrow.example.invalid/", ) def test_an_unlisted_address_selects_nothing(self): self.assertIsNone(rdap.server_for_ip("203.0.113.9", self.IPV4)) def test_a_tld_selects_its_registry(self): self.assertEqual( rdap.server_for_tld("invalid", self.DNS), "https://registry.example.invalid/", ) def test_tld_matching_ignores_case(self): self.assertEqual( rdap.server_for_tld("INVALID", self.DNS), "https://registry.example.invalid/", ) def test_an_unlisted_tld_selects_nothing(self): 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()