fix: an IPv6 domain overwrite applies only on a whole-host match, never a substring splice

overwrite_domain accepted a bare IPv6 replacement, but _apply_domain_overwrites spliced it
as a substring into any host CONTAINING the target, composing an invalid host ('internal::1'
from '.local'->'::1' on internal.local) that exploded deep in yarl at request time. An IPv6
replacement now applies ONLY when the target equals the whole request host; a substring match
is left unrewritten instead of building an invalid host. Hostname/IPv4 substring splices are
unchanged. (Registration can't distinguish whole-host from substring for a hostname target
without the request host, so the guard lives at compose time where the host is known.)

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
2026-07-06 19:56:52 -04:00
parent ed773e2c1b
commit eed607b08c
+30 -3
View File
@@ -40,6 +40,16 @@ def _is_ip_host(host) -> bool:
return True return True
def _is_ipv6_literal(host) -> bool:
"""whether host is a bare IPv6 literal (must be a whole-host swap, never a substring splice)"""
if not host:
return False
try:
return isinstance(ipaddress.ip_address(host), ipaddress.IPv6Address)
except ValueError:
return False
class _RetryStatus(Exception): class _RetryStatus(Exception):
"""internal signal: a retryable HTTP status; carries the real Response through aretry""" """internal signal: a retryable HTTP status; carries the real Response through aretry"""
@@ -186,12 +196,20 @@ class ExtendedSession:
# domain overwrites # domain overwrites
def overwrite_domain(self, target, replacement): def overwrite_domain(self, target, replacement):
"""register a host-substring rewrite (target -> replacement) """register a host rewrite (target -> replacement)
raises ValueError if the replacement is not a valid host for yarl's with_host() raises ValueError if the replacement is not a valid host for yarl's with_host()
(e.g. a 'host:port' string or a pre-bracketed '[::1]') - caught here rather than (e.g. a 'host:port' string or a pre-bracketed '[::1]') - caught here rather than
deep in yarl at request time. a bare IPv6 literal ('::1', '2001:db8::1') is valid deep in yarl at request time. a bare IPv6 literal ('::1', '2001:db8::1') is valid
and accepted: yarl brackets it itself. and accepted: yarl brackets it itself.
a hostname/IPv4 replacement rewrites any host CONTAINING target (substring splice,
e.g. 'example.com' -> 'internal.example.com'). an IPv6 replacement can only ever be
a WHOLE-HOST swap - splicing '::1' into the middle of a name yields an invalid host
('internal::1') - so at request time an IPv6 replacement applies ONLY when target
equals the whole request host; a request whose host merely CONTAINS the target (a
substring) is left unrewritten instead of composing an invalid host and exploding
deep in yarl. see _apply_domain_overwrites.
""" """
try: try:
URL("http://placeholder").with_host(replacement) URL("http://placeholder").with_host(replacement)
@@ -204,12 +222,21 @@ class ExtendedSession:
self.domain_overwrites[target] = replacement self.domain_overwrites[target] = replacement
def _apply_domain_overwrites(self, url: str) -> str: def _apply_domain_overwrites(self, url: str) -> str:
"""apply any host-substring rewrites to a url""" """apply any host rewrites to a url
a hostname/IPv4 replacement splices on a substring match; an IPv6 replacement only
applies on a whole-host (exact) match, since it can't be spliced mid-host - this pairs
with overwrite_domain's registration guard so an IPv6 rewrite never composes an
invalid host.
"""
parsed = URL(url) parsed = URL(url)
if not parsed.host: if not parsed.host:
return url return url
for target, replacement in self.domain_overwrites.items(): for target, replacement in self.domain_overwrites.items():
if target in parsed.host: if _is_ipv6_literal(replacement):
if target == parsed.host:
return str(parsed.with_host(replacement))
elif target in parsed.host:
return str(parsed.with_host(parsed.host.replace(target, replacement))) return str(parsed.with_host(parsed.host.replace(target, replacement)))
return url return url