61 lines
2.0 KiB
Python
61 lines
2.0 KiB
Python
"""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
|