diff options
Diffstat (limited to 'tests/test_rdap.py')
| -rw-r--r-- | tests/test_rdap.py | 108 |
1 files 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__": |
