aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_rdap.py108
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__":