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 DestinationRejected(ValueError): pass async def system_resolver(host: str, port: int) -> list[str]: loop = asyncio.get_running_loop() records = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM) return sorted({record[4][0] for record in records}) def redacted_url(url: str) -> str: parts = urlsplit(url) host = parts.hostname or "" authority = f"{host}:{parts.port}" if parts.port else host query = "" if parts.query else "" return urlunsplit((parts.scheme, authority, parts.path or "/", query, "")) async def require_public_destination(url: str, resolver: Resolver = system_resolver) -> None: parts = urlsplit(url) if parts.scheme not in {"http", "https"} or not parts.hostname: raise DestinationRejected("only HTTP(S) destinations are allowed") if parts.username is not None or parts.password is not None: raise DestinationRejected("URL user information is not allowed") try: port = parts.port or (443 if parts.scheme == "https" else 80) except ValueError as exc: raise DestinationRejected("invalid destination port") from exc try: addresses = await resolver(parts.hostname, port) except (OSError, UnicodeError) as exc: raise DestinationRejected("destination DNS resolution failed") from exc if not addresses: raise DestinationRejected("destination DNS resolution returned no addresses") try: parsed = [ipaddress.ip_address(address) for address in addresses] except ValueError as exc: raise DestinationRejected("destination DNS returned an invalid address") from exc if any(not address.is_global for address in parsed): raise DestinationRejected("destination resolves to a non-public address")