fischer-agentkit/src/agentkit/utils/security.py

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