Coverage for chalicelib/utils/ssrf.py: 68%
88 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:56 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:56 +0000
1import ipaddress
2import socket
3from urllib.parse import urlparse, urlunparse
5import requests
6from decouple import config
7from requests.adapters import HTTPAdapter
10def get_allowed_hosts():
11 return {h.strip().lower() for h in config("WEBHOOK_ALLOWED_HOSTS", default="").split(",") if h.strip()}
14def resolve_public_ip(endpoint):
15 """Validates that the endpoint resolves to public IPs only and returns one of them,
16 so the request can be pinned to it (protects against DNS-rebinding between check and request).
17 Returns None (no validation, no pinning) for hosts listed in WEBHOOK_ALLOWED_HOSTS."""
18 parsed = urlparse(endpoint)
19 hostname = parsed.hostname
20 if not hostname: 20 ↛ 21line 20 didn't jump to line 21 because the condition on line 20 was never true
21 raise ValueError(f"endpoint has no valid hostname: {endpoint}")
22 if hostname.lower() in get_allowed_hosts(): 22 ↛ 23line 22 didn't jump to line 23 because the condition on line 22 was never true
23 return None
24 try:
25 resolved = socket.getaddrinfo(hostname, parsed.port or (443 if parsed.scheme == "https" else 80),
26 proto=socket.IPPROTO_TCP)
27 except socket.gaierror as e:
28 raise ValueError(f"endpoint hostname could not be resolved: {hostname}") from e
29 ips = []
30 for family, _, _, _, sockaddr in resolved:
31 ip = ipaddress.ip_address(sockaddr[0])
32 if not ip.is_global:
33 raise ValueError(f"endpoint resolves to a non-public IP address: {hostname} -> {ip}")
34 ips.append(ip)
35 return str(ips[0])
38class PinnedHostHTTPSAdapter(HTTPAdapter):
39 """Keeps TLS SNI and certificate validation bound to the original hostname
40 while the connection itself targets a pre-resolved IP."""
42 def __init__(self, hostname, **kwargs):
43 self._hostname = hostname
44 super().__init__(**kwargs)
46 def init_poolmanager(self, *args, **kwargs):
47 kwargs["server_hostname"] = self._hostname
48 kwargs["assert_hostname"] = self._hostname
49 super().init_poolmanager(*args, **kwargs)
52def post_json(endpoint, json_data, headers=None, timeout=None):
53 """POSTs JSON to an endpoint after validating that it targets a public IP (SSRF guard).
54 The connection is pinned to the validated IP and redirects are not followed.
55 Raises ValueError if the endpoint is non-public and not in WEBHOOK_ALLOWED_HOSTS."""
56 endpoint = str(endpoint)
57 headers = headers or {}
58 pinned_ip = resolve_public_ip(endpoint)
59 if pinned_ip is None: 59 ↛ 60line 59 didn't jump to line 60 because the condition on line 59 was never true
60 return requests.post(url=endpoint, json=json_data, headers=headers, timeout=timeout,
61 allow_redirects=False)
62 parsed = urlparse(endpoint)
63 hostname = parsed.hostname
64 ip_host = f"[{pinned_ip}]" if ":" in pinned_ip else pinned_ip
65 netloc = ip_host if parsed.port is None else f"{ip_host}:{parsed.port}"
66 pinned_url = urlunparse(parsed._replace(netloc=netloc))
67 headers = {**headers, "Host": hostname if parsed.port is None else f"{hostname}:{parsed.port}"}
68 with requests.Session() as s:
69 if parsed.scheme == "https": 69 ↛ 71line 69 didn't jump to line 71 because the condition on line 69 was always true
70 s.mount("https://", PinnedHostHTTPSAdapter(hostname))
71 return s.post(url=pinned_url, json=json_data, headers=headers, timeout=timeout,
72 allow_redirects=False)
75def get_integration_allowed_hosts():
76 """Hosts exempted from the public-IP requirement for user-configured
77 integrations (e.g. an Elasticsearch instance on a private network)."""
78 return {h.strip().lower() for h in config("INTEGRATION_ALLOWED_HOSTS", default="").split(",") if h.strip()}
81def is_public_host(host: str, port=None) -> bool:
82 """Returns True only when the bare hostname/IP resolves exclusively to
83 public IPs (blocks loopback, RFC1918, link-local incl. cloud metadata
84 endpoints, CGNAT, ULA, reserved and multicast ranges)."""
85 if not host:
86 return False
87 try:
88 infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
89 except (socket.gaierror, UnicodeError):
90 return False
91 if len(infos) == 0: 91 ↛ 92line 91 didn't jump to line 92 because the condition on line 91 was never true
92 return False
93 for info in infos: 93 ↛ 100line 93 didn't jump to line 100 because the loop on line 93 didn't complete
94 try:
95 ip = ipaddress.ip_address(info[4][0])
96 except ValueError:
97 return False
98 if not ip.is_global or ip.is_multicast: 98 ↛ 93line 98 didn't jump to line 93 because the condition on line 98 was always true
99 return False
100 return True
103ALLOWED_SCHEMES = ("http", "https")
104ALLOWED_PORTS = (80, 443)
107def is_safe_external_url(url: str) -> bool:
108 """SSRF guard for URLs fetched server-side on behalf of user-controlled data
109 (e.g. sourcemap URLs coming from error stack frames): http(s) only, no
110 embedded credentials, standard ports, and every IP the hostname resolves to
111 must be public (blocks loopback, RFC1918, link-local incl. cloud metadata
112 endpoints, CGNAT, ULA, reserved and multicast ranges).
114 Note: DNS is resolved here for validation only; the actual fetch resolves
115 again, so consumers must re-validate at connect time to rule out DNS
116 rebinding (sourcemapreader does this via its lookup hook)."""
117 try:
118 p = urlparse(url)
119 except ValueError:
120 return False
121 if p.scheme not in ALLOWED_SCHEMES:
122 return False
123 if not p.hostname or p.username or p.password:
124 return False
125 try:
126 port = p.port
127 except ValueError:
128 return False
129 port = port or (443 if p.scheme == "https" else 80)
130 if port not in ALLOWED_PORTS:
131 return False
132 return is_public_host(p.hostname, port)