aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-09 09:47:51 +0200
committerDanilo M. <danix@danix.xyz>2026-09-09 09:47:51 +0200
commit745473b0edecc16915f761b15e58b23bc5bd69b9 (patch)
tree08565af9a952603047f45e8082fb0d990e996e70
parent7fbd109ed8e00ca1184015ba50c30ee92cce1ad8 (diff)
downloadabusectl-745473b0edecc16915f761b15e58b23bc5bd69b9.tar.gz
abusectl-745473b0edecc16915f761b15e58b23bc5bd69b9.zip
fix: keep ":" literal so an IPv6 query is not over-encoded
6d5822b quoted the interpolated component with safe="", which encoded the colons of every IPv6 address: 2001:db8::1 went on the wire as 2001%3Adb8%3A%3A1. RFC 9082 specifies the IPv6 object path segment as the address in its text form, and RFC 3986 pchar permits ":" in a segment, so the colon must stay literal. The failure mode was the bad one for this tool: 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", losing the abuse contact silently on exactly the IPv6 hops the tool exists to report. safe=":" keeps everything the original commit was for. "/" is still encoded, so the traversal containment is unchanged, and "%" is still encoded, so a scope id stays contained. The regression got through because the happy-path guard used an IPv4 address only. The new test asserts a documentation-range IPv6 address round-trips into the URL unencoded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
-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()