aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--abusectl/rdap.py21
-rw-r--r--tests/test_rdap.py23
2 files changed, 34 insertions, 10 deletions
diff --git a/abusectl/rdap.py b/abusectl/rdap.py
index 14099e2..286d960 100644
--- a/abusectl/rdap.py
+++ b/abusectl/rdap.py
@@ -328,18 +328,27 @@ _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.
+ Not 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.
+
+ ":" is safe and MUST stay literal. RFC 9082 specifies the IPv6 object
+ path segment as the address in its text form, and RFC 3986 pchar
+ permits ":" in a segment, so 2001:db8::1 goes on the wire as written.
+ Encoding it does not fail loudly: a registry that does not normalise
+ before matching answers 404, which reads here as "this netblock
+ publishes no abuse desk" rather than "we asked the wrong question", so
+ the abuse contact for an IPv6 hop is lost silently on exactly the hops
+ this tool exists to report.
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="")
+ return urllib.parse.quote(component, safe=":")
def query_ip(address: str, bootstrap_data: dict, fetch=http_fetch) -> dict | None:
diff --git a/tests/test_rdap.py b/tests/test_rdap.py
index 815c311..1045160 100644
--- a/tests/test_rdap.py
+++ b/tests/test_rdap.py
@@ -459,6 +459,7 @@ class QuotedQueryComponent(unittest.TestCase):
IPV4 = {"services": [[["198.51.100.0/24"], ["https://rir.example.invalid/"]]]}
IPV6 = {"services": [[["fe80::/10"], ["https://rir.example.invalid/"]]]}
+ DOCV6 = {"services": [[["2001:db8::/32"], ["https://r6.example.invalid/"]]]}
DNS = {"services": [[["invalid"], ["https://registry.example.invalid/"]]]}
def _recorder(self):
@@ -493,14 +494,28 @@ class QuotedQueryComponent(unittest.TestCase):
"""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.
+ wire through the normal path. The colons stay literal; only the
+ scope separator is encoded.
"""
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"]
- )
+ self.assertEqual(calls, ["https://rir.example.invalid/ip/fe80::1%25eth0"])
+
+ def test_a_normal_ipv6_address_is_not_encoded_at_all(self):
+ """RFC 9082 wants the address in its TEXT form, and pchar allows ":".
+
+ Encoding the colon does not fail loudly. A registry that does not
+ normalise before matching answers 404, which this tool reads as
+ "this netblock publishes no abuse desk" rather than "we asked the
+ wrong question", and the abuse contact for an IPv6 hop is lost
+ silently. The happy-path guard was IPv4 only, so an over-broad
+ safe="" got through once already.
+ """
+ calls, fetch = self._recorder()
+ rdap.query_ip("2001:db8::1", self.DOCV6, fetch=fetch)
+
+ self.assertEqual(calls, ["https://r6.example.invalid/ip/2001:db8::1"])
def test_a_query_and_fragment_in_a_host_are_not_live(self):
calls, fetch = self._recorder()