"""Security utilities for URL validation.""" import ipaddress from urllib.parse import urlparse def is_safe_url(url: str) -> bool: """Check if URL is safe (not pointing to private/internal networks). Validates against: - Private/loopback/reserved/link-local/unspecified IPs - IPv6-mapped IPv4 addresses (e.g. ::ffff:127.0.0.1) - Common metadata/internal hostnames - Non-HTTP schemes """ try: parsed = urlparse(url) if parsed.scheme not in ("http", "https"): return False hostname = parsed.hostname if not hostname: return False # Block known internal/metadata hostnames _blocked_hostnames = { "localhost", "metadata.google.internal", "metadata.internal", "metadata.azure.com", } hostname_lower = hostname.lower() if hostname_lower in _blocked_hostnames: return False if hostname_lower.endswith((".internal", ".local", ".localhost")): return False try: ip = ipaddress.ip_address(hostname) if _is_unsafe_ip(ip): return False except ValueError: # hostname is a domain, not a literal IP — DNS rebinding risk remains # (would need DNS resolution to fully mitigate) pass return True except Exception: return False def _is_unsafe_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: """Check if an IP address is unsafe (private, loopback, etc.). Handles IPv6-mapped IPv4 addresses by checking the embedded IPv4. """ if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local or ip.is_unspecified: return True # Check IPv6-mapped IPv4 (e.g. ::ffff:127.0.0.1) if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None: mapped = ip.ipv4_mapped if mapped.is_private or mapped.is_loopback or mapped.is_reserved or mapped.is_link_local: return True return False