aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--abusectl/rdap.py25
-rw-r--r--tests/test_rdap.py89
2 files changed, 112 insertions, 2 deletions
diff --git a/abusectl/rdap.py b/abusectl/rdap.py
index 66638c7..14099e2 100644
--- a/abusectl/rdap.py
+++ b/abusectl/rdap.py
@@ -31,6 +31,7 @@ discipline _MAX_REDIRECT_DEPTH already sets in parse.py.
import json
import urllib.error
+import urllib.parse
import urllib.request
_TIMEOUT = 10
@@ -324,6 +325,23 @@ def abuse_addresses(response: dict) -> list[str]:
_MAX_LABEL_WALK = 5
+def _quoted(component: str) -> str:
+ """Percent-encode a component so it cannot escape its path segment.
+
+ safe="" rather than urllib's default safe="/": leaving the separator
+ intact is exactly the traversal shape this guards against, and a
+ component ending in "/../.." would address a path the registry never
+ published. A bare "%", as an IPv6 scope id carries, is a truncated
+ escape rather than a literal percent, so it is encoded too.
+
+ Defence in depth, not validation. contacts.is_queryable() is the
+ admission point and stays the real gate; this is the layer that holds
+ if a later branch reaches these functions without passing through it,
+ which is how the same property leaked twice already.
+ """
+ return urllib.parse.quote(component, safe="")
+
+
def query_ip(address: str, bootstrap_data: dict, fetch=http_fetch) -> dict | None:
"""Query the registry responsible for an address.
@@ -333,7 +351,7 @@ def query_ip(address: str, bootstrap_data: dict, fetch=http_fetch) -> dict | Non
base = server_for_ip(address, bootstrap_data)
if base is None:
return None
- return fetch(f"{base.rstrip('/')}/ip/{address}")
+ return fetch(f"{base.rstrip('/')}/ip/{_quoted(address)}")
def query_domain(
@@ -367,7 +385,10 @@ def query_domain(
candidate = ".".join(labels[start:])
attempts += 1
try:
- return fetch(f"{base}/domain/{candidate}"), candidate
+ # The URL is quoted, the returned candidate is NOT: the manifest
+ # records and the review dialog shows the name the user has to
+ # recognise, and %2E%2E is not that name.
+ return fetch(f"{base}/domain/{_quoted(candidate)}"), candidate
except Exception:
continue
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()