from __future__ import annotations import ipaddress import socket from urllib.parse import urlsplit, urlunsplit class UnsafeConnectorUrl(ValueError): pass _BLOCKED_HOSTS = {"localhost", "localhost.localdomain", "metadata.google.internal"} _BLOCKED_IPS = { ipaddress.ip_address("169.254.169.254"), ipaddress.ip_address("100.100.100.200"), } def _address_is_forbidden(address: ipaddress.IPv4Address | ipaddress.IPv6Address, *, allow_loopback: bool) -> bool: return ( address in _BLOCKED_IPS or address.is_link_local or address.is_multicast or address.is_unspecified or (address.is_loopback and not allow_loopback) ) def validate_connector_url(value: str, *, allow_loopback: bool = False) -> str: """Validate a configured LAN connector URL and return a normalized value.""" raw = str(value or "").strip() if not raw: return "" parsed = urlsplit(raw) if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname: raise UnsafeConnectorUrl("connector_url_requires_http_or_https") if parsed.username or parsed.password or parsed.fragment: raise UnsafeConnectorUrl("connector_url_contains_forbidden_components") try: port = parsed.port except ValueError as exc: raise UnsafeConnectorUrl("connector_url_has_invalid_port") from exc if port is not None and not 1 <= port <= 65535: raise UnsafeConnectorUrl("connector_url_has_invalid_port") hostname = parsed.hostname.rstrip(".").lower() if hostname in _BLOCKED_HOSTS and not allow_loopback: raise UnsafeConnectorUrl("connector_url_loopback_blocked") try: address = ipaddress.ip_address(hostname) except ValueError: address = None if address is not None: if _address_is_forbidden(address, allow_loopback=allow_loopback): raise UnsafeConnectorUrl("connector_url_reserved_address_blocked") else: # Inspect currently resolvable names as well as literal IPs. Unresolved LAN # names remain configurable, but DNS names resolving to local-only system # addresses cannot bypass the literal-address checks. try: resolved = {item[4][0] for item in socket.getaddrinfo(hostname, port or 0, type=socket.SOCK_STREAM)} except socket.gaierror: resolved = set() for resolved_value in resolved: resolved_address = ipaddress.ip_address(resolved_value) if _address_is_forbidden(resolved_address, allow_loopback=allow_loopback): raise UnsafeConnectorUrl("connector_url_resolves_to_reserved_address") normalized_path = parsed.path.rstrip("/") return urlunsplit((parsed.scheme.lower(), parsed.netloc, normalized_path, parsed.query, ""))