From eed607b08c4de0460468a76a1100b7bdf846d839 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Mon, 6 Jul 2026 19:56:52 -0400 Subject: [PATCH] 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 --- src/aioweb/session.py | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/aioweb/session.py b/src/aioweb/session.py index 8c3c189..abea816 100644 --- a/src/aioweb/session.py +++ b/src/aioweb/session.py @@ -40,6 +40,16 @@ def _is_ip_host(host) -> bool: 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): """internal signal: a retryable HTTP status; carries the real Response through aretry""" @@ -186,12 +196,20 @@ class ExtendedSession: # domain overwrites 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() (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 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: URL("http://placeholder").with_host(replacement) @@ -204,12 +222,21 @@ class ExtendedSession: self.domain_overwrites[target] = replacement 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) if not parsed.host: return url 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 url