FossilRepo

fossilrepo / core / url_validation.py
Source Blame History 61 lines
fcd8df3… ragelink 1 """URL validation for outbound requests (webhooks, etc.)."""
fcd8df3… ragelink 2
fcd8df3… ragelink 3 import ipaddress
fcd8df3… ragelink 4 import socket
fcd8df3… ragelink 5 from urllib.parse import urlparse
fcd8df3… ragelink 6
fcd8df3… ragelink 7
7e1aaf6… ragelink 8 def is_safe_outbound_url(url: str) -> tuple[bool, str]:
fcd8df3… ragelink 9 """Validate a webhook URL is safe for server-side requests.
fcd8df3… ragelink 10
fcd8df3… ragelink 11 Blocks:
fcd8df3… ragelink 12 - Non-HTTP(S) protocols
fcd8df3… ragelink 13 - Localhost and loopback addresses
fcd8df3… ragelink 14 - Private/internal IP ranges (10.x, 172.16-31.x, 192.168.x, etc.)
fcd8df3… ragelink 15 - Link-local addresses
fcd8df3… ragelink 16 - AWS metadata endpoint (169.254.169.254)
fcd8df3… ragelink 17
fcd8df3… ragelink 18 Returns (is_safe, error_message).
fcd8df3… ragelink 19 """
fcd8df3… ragelink 20 if not url:
fcd8df3… ragelink 21 return False, "URL is required."
fcd8df3… ragelink 22
fcd8df3… ragelink 23 parsed = urlparse(url)
fcd8df3… ragelink 24
fcd8df3… ragelink 25 if parsed.scheme not in ("http", "https"):
fcd8df3… ragelink 26 return False, "Only http:// and https:// URLs are allowed."
fcd8df3… ragelink 27
fcd8df3… ragelink 28 hostname = parsed.hostname
fcd8df3… ragelink 29 if not hostname:
fcd8df3… ragelink 30 return False, "URL must include a hostname."
fcd8df3… ragelink 31
fcd8df3… ragelink 32 # Block obvious localhost variants
fcd8df3… ragelink 33 if hostname in ("localhost", "127.0.0.1", "::1", "0.0.0.0"):
fcd8df3… ragelink 34 return False, "Localhost URLs are not allowed."
fcd8df3… ragelink 35
fcd8df3… ragelink 36 # Resolve hostname and check the IP
fcd8df3… ragelink 37 try:
fcd8df3… ragelink 38 addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
fcd8df3… ragelink 39 except socket.gaierror:
fcd8df3… ragelink 40 return False, f"Could not resolve hostname: {hostname}"
fcd8df3… ragelink 41
fcd8df3… ragelink 42 for _family, _type, _proto, _canonname, sockaddr in addr_info:
fcd8df3… ragelink 43 ip_str = sockaddr[0]
fcd8df3… ragelink 44 try:
fcd8df3… ragelink 45 ip = ipaddress.ip_address(ip_str)
fcd8df3… ragelink 46 except ValueError:
fcd8df3… ragelink 47 continue
fcd8df3… ragelink 48
fcd8df3… ragelink 49 if ip.is_loopback:
fcd8df3… ragelink 50 return False, "Loopback addresses are not allowed."
fcd8df3… ragelink 51 if ip.is_private:
fcd8df3… ragelink 52 return False, "Private/internal IP addresses are not allowed."
fcd8df3… ragelink 53 if ip.is_link_local:
fcd8df3… ragelink 54 return False, "Link-local addresses are not allowed."
fcd8df3… ragelink 55 if ip.is_reserved:
fcd8df3… ragelink 56 return False, "Reserved IP addresses are not allowed."
fcd8df3… ragelink 57 # AWS metadata endpoint
fcd8df3… ragelink 58 if ip_str == "169.254.169.254":
fcd8df3… ragelink 59 return False, "Cloud metadata endpoints are not allowed."
fcd8df3… ragelink 60
fcd8df3… ragelink 61 return True, ""

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button