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