diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-09 09:45:18 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-09 09:45:18 +0200 |
| commit | 6d5822b57bed2e2114814d8063d497372ea378ee (patch) | |
| tree | ce6f6d7f1dab6cf5c0bcd342527dff20817ba951 /tests | |
| parent | 524678a063a9bee80ce07e66ddc0ee18af4e4622 (diff) | |
| download | abusectl-6d5822b57bed2e2114814d8063d497372ea378ee.tar.gz abusectl-6d5822b57bed2e2114814d8063d497372ea378ee.zip | |
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_rdap.py | 89 |
1 files changed, 89 insertions, 0 deletions
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() |
