import asyncio import ipaddress import socket from collections.abc import Awaitable, Callable from urllib.parse import urlsplit, urlunsplit Resolver = Callable[[str, int], Awaitable[list[str]]] class UnsafeTarget(ValueError): pass async def system_resolver(host: str, port: int) -> list[str]: def resolve() -> list[str]: records = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) return sorted({record[4][0] for record in records}) try: return await asyncio.to_thread(resolve) except socket.gaierror as exc: raise OSError(f"DNS resolution failed: {exc}") from exc def _require_global(address: str) -> None: try: ip = ipaddress.ip_address(address) except ValueError as exc: raise UnsafeTarget("DNS returned an invalid address") from exc if not ip.is_global: raise UnsafeTarget("target resolves to a non-public address") async def validate_target(url: str, resolver: Resolver = system_resolver) -> None: parsed = urlsplit(url) if parsed.scheme not in {"http", "https"}: raise UnsafeTarget("only HTTP(S) targets are allowed") if parsed.username is not None or parsed.password is not None: raise UnsafeTarget("URL credentials are not allowed") if not parsed.hostname: raise UnsafeTarget("target has no hostname") try: literal = ipaddress.ip_address(parsed.hostname) except ValueError: addresses = await resolver(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80)) if not addresses: raise OSError("DNS resolution returned no addresses") for address in addresses: _require_global(address) else: _require_global(str(literal)) def redact_url(url: str) -> str: """Retain scheme/host/port/path, never credentials, query, or fragment.""" parsed = urlsplit(url) host = parsed.hostname or "" if ":" in host and not host.startswith("["): host = f"[{host}]" netloc = host try: if parsed.port is not None: netloc = f"{host}:{parsed.port}" except ValueError: netloc = host return urlunsplit((parsed.scheme, netloc, parsed.path, "", ""))