From 994572af87ec9256a5e8dc39a1486cffa2a7e6c7 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 9 Sep 2026 09:31:33 +0200 Subject: test: make two rdap tests able to fail, and cover the hostile bootstrap test_the_walk_is_capped could not fail. Its host had nine labels, so the bare-TLD guard alone bounded the walk to eight attempts and the cap was never reached, and the assertion read rdap._MAX_LABEL_WALK at assert time, so the expected value moved with any mutation. Raising the cap to a billion left the suite green. It now uses a twelve-label host and a hard-coded expected count of five, and raising the cap fails it with 11 != 5. test_a_newline_in_an_address_is_rejected asserted a guarantee that a different function happened to provide. email.utils.parseaddr rejects the CRLF fixture by itself, returning an empty address, so deleting both control-character checks from _valid_address left the suite green while the real defence was gone. parseaddr passes \x0b and \x0c straight through, and those reach a mail header raw, so a case for each is added alongside the CRLF one, which is still worth asserting. The new HostileBootstrap cases cover the bootstrap defects fixed in the previous commit: a planted integer CIDR, a malformed services value in seven shapes, a plaintext base URL, and the two paths where a bad entry must not hide a good one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz --- tests/test_rdap.py | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/tests/test_rdap.py b/tests/test_rdap.py index 9be5bcd..7f2f0d5 100644 --- a/tests/test_rdap.py +++ b/tests/test_rdap.py @@ -176,6 +176,96 @@ class ServerSelection(unittest.TestCase): self.assertIsNone(rdap.server_for_tld("example", self.DNS)) +class HostileBootstrap(unittest.TestCase): + """The bootstrap document comes off the same network as an RDAP response. + + abuse_addresses already treats a response as hostile. These assert the + same posture for the document that decides WHICH server is asked, which + is the more valuable one to subvert. + """ + + def test_a_non_string_cidr_does_not_become_a_winning_slash_32(self): + """ipaddress.ip_network(16909060) returns 1.2.3.4/32 rather than + raising, and a /32 is the longest possible prefix, so it wins every + contest. The victim is the user, who would file the phishing report + to a server the phisher chose.""" + data = { + "services": [ + [["1.2.3.0/24"], ["https://legit.example.invalid/"]], + [[16909060], ["https://attacker.example.invalid/"]], + ] + } + self.assertEqual( + rdap.server_for_ip("1.2.3.4", data), + "https://legit.example.invalid/", + ) + + def test_a_non_string_tld_name_does_not_crash(self): + data = {"services": [[[42], ["https://attacker.example.invalid/"]]]} + self.assertIsNone(rdap.server_for_tld("invalid", data)) + + def test_a_malformed_services_value_is_not_fatal(self): + """One bad response is cached for seven days, so a crash here breaks + every contacts run until the cache expires.""" + for services in ("abc", [[["192.0.2.0/24"]]], [None], [{"a": 1}], + [[5, ["https://u.example.invalid/"]]], 7, None): + with self.subTest(services=services): + data = {"services": services} + self.assertIsNone(rdap.server_for_ip("192.0.2.1", data)) + self.assertIsNone(rdap.server_for_tld("invalid", data)) + + def test_a_malformed_entry_does_not_hide_a_good_one(self): + data = { + "services": [ + None, + [[["192.0.2.0/24"]]], + [["192.0.2.0/24"], ["https://legit.example.invalid/"]], + ] + } + self.assertEqual( + rdap.server_for_ip("192.0.2.1", data), + "https://legit.example.invalid/", + ) + + def test_a_plaintext_base_url_is_refused(self): + """_NoDowngradeRedirectHandler guards redirects only. A bootstrap + naming an http:// base would send the query in clear text, + disclosing which netblock the user is investigating.""" + ipv4 = {"services": [[["198.51.100.0/24"], + ["http://plaintext.example.invalid/"]]]} + dns = {"services": [[["invalid"], + ["http://plaintext.example.invalid/"]]]} + self.assertIsNone(rdap.server_for_ip("198.51.100.7", ipv4)) + self.assertIsNone(rdap.server_for_tld("invalid", dns)) + + def test_a_non_string_url_is_refused(self): + ipv4 = {"services": [[["198.51.100.0/24"], [42]]]} + dns = {"services": [[["invalid"], [42]]]} + self.assertIsNone(rdap.server_for_ip("198.51.100.7", ipv4)) + self.assertIsNone(rdap.server_for_tld("invalid", dns)) + + def test_a_plaintext_base_does_not_hide_an_https_one(self): + data = { + "services": [ + [["192.0.2.0/25"], ["http://plaintext.example.invalid/"]], + [["192.0.2.0/24"], ["https://legit.example.invalid/"]], + ] + } + self.assertEqual( + rdap.server_for_ip("192.0.2.1", data), + "https://legit.example.invalid/", + ) + + def test_a_plaintext_base_is_never_queried(self): + data = {"services": [[["198.51.100.0/24"], + ["http://plaintext.example.invalid/"]]]} + + def fetch(url): + raise AssertionError(f"should not have fetched {url}") + + self.assertIsNone(rdap.query_ip("198.51.100.7", data, fetch=fetch)) + + def _entity(roles, emails, entities=None): """Build an RDAP entity in real jCard shape.""" properties = [["version", {}, "text", "4.0"]] @@ -238,6 +328,16 @@ class AbuseExtraction(unittest.TestCase): } self.assertEqual(rdap.abuse_addresses(response), []) + def test_a_bare_control_character_in_an_address_is_rejected(self): + """The CRLF case above is rejected by parseaddr itself, so it passes + even with the control-character check deleted. These two are passed + straight through by parseaddr and reach a mail header raw, so this + is the case that actually holds _valid_address's own defence.""" + for raw in ("abuse@example.invalid\x0b", "ab\x0cuse@example.invalid"): + with self.subTest(raw=raw): + response = {"entities": [_entity(["abuse"], [raw])]} + 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), []) @@ -336,9 +436,13 @@ class Query(unittest.TestCase): calls.append(url) raise urllib.error.HTTPError(url, 404, "Not Found", {}, None) - host = "a.b.c.d.e.f.g.example.invalid" + # Twelve labels, so the bare-TLD guard alone would allow eleven + # attempts and only the cap can stop the walk at five. The expected + # count is hard-coded: reading rdap._MAX_LABEL_WALK here would move + # the assertion along with the mutation and the test could not fail. + host = "a.b.c.d.e.f.g.h.i.j.example.invalid" rdap.query_domain(host, self.DNS, fetch=fetch) - self.assertLessEqual(len(calls), rdap._MAX_LABEL_WALK) + self.assertEqual(len(calls), 5) if __name__ == "__main__": -- cgit v1.2.3