From 6d5822b57bed2e2114814d8063d497372ea378ee Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 9 Sep 2026 09:45:18 +0200 Subject: fix: percent-encode the component interpolated into an RDAP query contacts.is_queryable() is the admission point and remains the real gate, but this property has leaked three times already, each time the same shape: a validator applied to one branch and forgotten on its sibling. First the url branch was guarded and domain leaked, then domain was guarded and the IP branch leaked through an IPv6 scope id. query_ip and query_domain interpolated their component straight into the path, so a caller reaching them without passing through worklist() could put a separator, a query or a fragment on the wire. safe="" rather than urllib's default safe="/" is the point: the default leaves the path separator intact, which is exactly the traversal shape that leaked before. query_domain still returns the UNQUOTED candidate, because that is what the manifest records and the review dialog shows. The tests call both functions directly, bypassing contacts, since a future branch that skips the admission point is the failure this layer exists to survive. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz --- tests/test_rdap.py | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) (limited to 'tests/test_rdap.py') diff --git a/tests/test_rdap.py b/tests/test_rdap.py index 7f2f0d5..815c311 100644 --- a/tests/test_rdap.py +++ b/tests/test_rdap.py @@ -445,5 +445,94 @@ class Query(unittest.TestCase): self.assertEqual(len(calls), 5) +class QuotedQueryComponent(unittest.TestCase): + """The interpolated component cannot escape its path segment. + + contacts.is_queryable() is the admission point and validates every + candidate, but that guarantee has already leaked three times in this + module's history, each time the same shape: a validator applied to one + branch and forgotten on its sibling. These call query_ip and + query_domain DIRECTLY, bypassing contacts entirely, because a future + branch that skips worklist() is exactly the failure this layer exists + to survive. + """ + + IPV4 = {"services": [[["198.51.100.0/24"], ["https://rir.example.invalid/"]]]} + IPV6 = {"services": [[["fe80::/10"], ["https://rir.example.invalid/"]]]} + DNS = {"services": [[["invalid"], ["https://registry.example.invalid/"]]]} + + def _recorder(self): + calls = [] + + def fetch(url): + calls.append(url) + return {"handle": "X", "entities": []} + + return calls, fetch + + def test_a_traversal_in_an_ip_stays_under_the_ip_segment(self): + """server_for_ip refuses this string today, so the base is forced. + + Forcing it is the point: this asserts what query_ip does with a + component it was handed, not what today's lookup happens to reject. + """ + calls, fetch = self._recorder() + original = rdap.server_for_ip + rdap.server_for_ip = lambda address, data: "https://rir.example.invalid/" + try: + rdap.query_ip("198.51.100.7/../../etc", self.IPV4, fetch=fetch) + finally: + rdap.server_for_ip = original + + self.assertEqual( + calls, + ["https://rir.example.invalid/ip/198.51.100.7%2F..%2F..%2Fetc"], + ) + + def test_a_scope_id_in_an_ip_is_encoded_not_left_malformed(self): + """A bare % in a URL is a truncated escape, not a literal percent. + + server_for_ip accepts fe80::1%eth0 today, so this one reaches the + wire through the normal path. + """ + calls, fetch = self._recorder() + rdap.query_ip("fe80::1%eth0", self.IPV6, fetch=fetch) + + self.assertEqual( + calls, ["https://rir.example.invalid/ip/fe80%3A%3A1%25eth0"] + ) + + def test_a_query_and_fragment_in_a_host_are_not_live(self): + calls, fetch = self._recorder() + rdap.query_domain("victim?e=x#frag.example.invalid", self.DNS, fetch=fetch) + + self.assertEqual(len(calls), 1) + url = calls[0] + self.assertNotIn("?", url) + self.assertNotIn("#", url) + self.assertIn("%3F", url) + self.assertIn("%23", url) + + def test_a_normal_ip_url_is_unchanged(self): + calls, fetch = self._recorder() + rdap.query_ip("198.51.100.7", self.IPV4, fetch=fetch) + self.assertEqual(calls, ["https://rir.example.invalid/ip/198.51.100.7"]) + + def test_a_normal_host_url_is_unchanged(self): + calls, fetch = self._recorder() + rdap.query_domain("example.invalid", self.DNS, fetch=fetch) + self.assertEqual( + calls, ["https://registry.example.invalid/domain/example.invalid"] + ) + + def test_the_returned_candidate_is_unquoted(self): + """The manifest and the review dialog show the name, not the URL.""" + calls, fetch = self._recorder() + _, queried = rdap.query_domain( + "victim?e=x#frag.example.invalid", self.DNS, fetch=fetch + ) + self.assertEqual(queried, "victim?e=x#frag.example.invalid") + + if __name__ == "__main__": unittest.main() -- cgit v1.2.3