FossilRepo
| 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, "" |