main
py 51 lines 1.58 KB
Raw
1 from __future__ import annotations
2
3 import ipaddress
4 import socket
5 from urllib.parse import urlparse
6
7
8 BLOCKED_HOSTS = {"localhost", "metadata.google.internal"}
9 BLOCKED_IPS = {
10 ipaddress.ip_address("169.254.169.254"),
11 ipaddress.ip_address("100.100.100.200"),
12 }
13
14
15 class UnsafeUrlError(ValueError):
16 pass
17
18
19 def validate_public_http_url(url: str, *, resolve_dns: bool = False) -> str:
20 parsed = urlparse(url)
21 if parsed.scheme not in {"http", "https"}:
22 raise UnsafeUrlError("Only http and https URLs are permitted")
23 if not parsed.hostname:
24 raise UnsafeUrlError("URL host is required")
25 host = parsed.hostname.lower()
26 if host in BLOCKED_HOSTS or host.endswith(".localhost"):
27 raise UnsafeUrlError("Localhost and metadata hosts are not permitted")
28 try:
29 ip = ipaddress.ip_address(host)
30 except ValueError:
31 if resolve_dns:
32 for _, _, _, _, sockaddr in socket.getaddrinfo(host, parsed.port or 443):
33 ip = ipaddress.ip_address(sockaddr[0])
34 if _is_blocked_ip(ip):
35 raise UnsafeUrlError("Resolved private or metadata IP is not permitted")
36 else:
37 if _is_blocked_ip(ip):
38 raise UnsafeUrlError("Private, local, link-local, multicast, and metadata IPs are not permitted")
39 return url
40
41
42 def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
43 return (
44 ip in BLOCKED_IPS
45 or ip.is_private
46 or ip.is_loopback
47 or ip.is_link_local
48 or ip.is_multicast
49 or ip.is_reserved
50 or ip.is_unspecified
51 )