Overuse may indicate design issues; consider polymorphism
if isinstance(addr, ipaddress.IPv4Address):
1"""SSRF protection policy with IP validation and DNS-aware URL checking."""23import asyncio4import dataclasses5import ipaddress6import os7import socket8import urllib.parse910from langchain_core._security._exceptions import SSRFBlockedError1112# ---------------------------------------------------------------------------13# Blocklist constants14# ---------------------------------------------------------------------------1516_BLOCKED_IPV4_NETWORKS: tuple[ipaddress.IPv4Network, ...] = tuple(17 ipaddress.IPv4Network(n)18 for n in (19 "10.0.0.0/8", # RFC 1918 - private class A20 "172.16.0.0/12", # RFC 1918 - private class B21 "192.168.0.0/16", # RFC 1918 - private class C22 "127.0.0.0/8", # RFC 1122 - loopback23 "169.254.0.0/16", # RFC 3927 - link-local24 "0.0.0.0/8", # RFC 1122 - "this network"25 "100.64.0.0/10", # RFC 6598 - shared/CGN address space26 "192.0.0.0/24", # RFC 6890 - IETF protocol assignments27 "192.0.2.0/24", # RFC 5737 - TEST-NET-1 (documentation)28 "198.18.0.0/15", # RFC 2544 - benchmarking29 "198.51.100.0/24", # RFC 5737 - TEST-NET-2 (documentation)30 "203.0.113.0/24", # RFC 5737 - TEST-NET-3 (documentation)31 "224.0.0.0/4", # RFC 5771 - multicast32 "240.0.0.0/4", # RFC 1112 - reserved for future use33 "255.255.255.255/32", # RFC 919 - limited broadcast34 )35)3637_BLOCKED_IPV6_NETWORKS: tuple[ipaddress.IPv6Network, ...] = tuple(38 ipaddress.IPv6Network(n)39 for n in (40 "::1/128", # RFC 4291 - loopback41 "fc00::/7", # RFC 4193 - unique local addresses (ULA)42 "fe80::/10", # RFC 4291 - link-local43 "ff00::/8", # RFC 4291 - multicast44 "::ffff:0:0/96", # RFC 4291 - IPv4-mapped IPv6 addresses45 "::0.0.0.0/96", # RFC 4291 - IPv4-compatible IPv6 (deprecated)46 "64:ff9b::/96", # RFC 6052 - NAT64 well-known prefix47 "64:ff9b:1::/48", # RFC 8215 - NAT64 discovery prefix48 )49)5051_CLOUD_METADATA_IPS: frozenset[str] = frozenset(52 {53 "169.254.169.254", # AWS, GCP, Azure, DigitalOcean, Oracle Cloud54 "169.254.170.2", # AWS ECS task metadata55 "169.254.170.23", # AWS EKS Pod Identity Agent56 "100.100.100.200", # Alibaba Cloud metadata57 "fd00:ec2::254", # AWS EC2 IMDSv2 over IPv6 (Nitro instances)58 "fd00:ec2::23", # AWS EKS Pod Identity Agent (IPv6)59 "fe80::a9fe:a9fe", # OpenStack Nova metadata (IPv6 link-local)60 }61)6263# Network ranges that are always blocked when block_cloud_metadata=True,64# independent of block_private_ips. The entire link-local range is used by65# cloud metadata services across providers.66_CLOUD_METADATA_NETWORKS: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = (67 ipaddress.IPv4Network("169.254.0.0/16"),68)6970_CLOUD_METADATA_HOSTNAMES: frozenset[str] = frozenset(71 {72 "metadata.google.internal",73 "metadata.amazonaws.com",74 "metadata",75 "instance-data",76 }77)7879_LOCALHOST_NAMES: frozenset[str] = frozenset(80 {81 "localhost",82 "localhost.localdomain",83 "host.docker.internal",84 }85)8687_K8S_SUFFIX = ".svc.cluster.local"8889_LOOPBACK_IPV4 = ipaddress.IPv4Network("127.0.0.0/8")90_LOOPBACK_IPV6 = ipaddress.IPv6Address("::1")9192# NAT64 well-known prefixes93_NAT64_PREFIX = ipaddress.IPv6Network("64:ff9b::/96")94_NAT64_DISCOVERY_PREFIX = ipaddress.IPv6Network("64:ff9b:1::/48")959697# ---------------------------------------------------------------------------98# SSRFPolicy99# ---------------------------------------------------------------------------100101102@dataclasses.dataclass(frozen=True)103class SSRFPolicy:104 """Immutable policy controlling which URLs/IPs are considered safe."""105106 allowed_schemes: frozenset[str] = frozenset({"http", "https"})107 block_private_ips: bool = True108 block_localhost: bool = True109 block_cloud_metadata: bool = True110 block_k8s_internal: bool = True111 allowed_hosts: frozenset[str] = frozenset()112 additional_blocked_cidrs: tuple[113 ipaddress.IPv4Network | ipaddress.IPv6Network, ...114 ] = ()115116117DEFAULT_SSRF_POLICY = SSRFPolicy()118119120# ---------------------------------------------------------------------------121# Helpers122# ---------------------------------------------------------------------------123124125def _extract_embedded_ipv4(126 addr: ipaddress.IPv6Address,127) -> ipaddress.IPv4Address | None:128 """Extract an embedded IPv4 from IPv4-mapped or NAT64 IPv6 addresses."""129 # Check ipv4_mapped first (covers ::ffff:x.x.x.x)130 if addr.ipv4_mapped is not None:131 return addr.ipv4_mapped132133 # Check NAT64 prefixes — embedded IPv4 is in the last 4 bytes134 if addr in _NAT64_PREFIX or addr in _NAT64_DISCOVERY_PREFIX:135 raw = addr.packed136 return ipaddress.IPv4Address(raw[-4:])137138 return None139140141def _ip_in_blocked_networks(142 addr: ipaddress.IPv4Address | ipaddress.IPv6Address,143 policy: SSRFPolicy,144) -> str | None:145 """Return a reason string if *addr* falls in a blocked range, else None."""146 # NOTE: if profiling shows this is a hot path, consider memoising with147 # @functools.lru_cache (key on (addr, id(policy))).148 if isinstance(addr, ipaddress.IPv4Address):149 if policy.block_private_ips:150 for blocked_ipv4_net in _BLOCKED_IPV4_NETWORKS:151 if addr in blocked_ipv4_net:152 return "private IP range"153 for blocked_cidr in policy.additional_blocked_cidrs:154 if isinstance(blocked_cidr, ipaddress.IPv4Network) and addr in blocked_cidr:155 return "blocked CIDR"156 else:157 if policy.block_private_ips:158 for blocked_ipv6_net in _BLOCKED_IPV6_NETWORKS:159 if addr in blocked_ipv6_net:160 return "private IP range"161 for blocked_cidr in policy.additional_blocked_cidrs:162 if isinstance(blocked_cidr, ipaddress.IPv6Network) and addr in blocked_cidr:163 return "blocked CIDR"164165 # Loopback check — independent of block_private_ips so that166 # block_localhost=True still catches 127.x.x.x / ::1 even when167 # private IPs are allowed.168 if policy.block_localhost:169 if isinstance(addr, ipaddress.IPv4Address) and (170 addr in _LOOPBACK_IPV4 or addr in ipaddress.IPv4Network("0.0.0.0/8")171 ):172 return "localhost address"173 if isinstance(addr, ipaddress.IPv6Address) and addr == _LOOPBACK_IPV6:174 return "localhost address"175176 # Cloud metadata check — IP set *and* network ranges (e.g. 169.254.0.0/16).177 # Independent of block_private_ips so that allow_private=True still blocks178 # cloud metadata endpoints.179 if policy.block_cloud_metadata:180 if str(addr) in _CLOUD_METADATA_IPS:181 return "cloud metadata endpoint"182 for net in _CLOUD_METADATA_NETWORKS:183 if addr in net:184 return "cloud metadata endpoint"185186 return None187188189# ---------------------------------------------------------------------------190# Public validation functions191# ---------------------------------------------------------------------------192193194def validate_resolved_ip(ip_str: str, policy: SSRFPolicy) -> None:195 """Validate a resolved IP address against the SSRF policy.196197 Raises SSRFBlockedError if the IP is blocked.198 """199 try:200 addr = ipaddress.ip_address(ip_str)201 except ValueError as exc:202 msg = "invalid IP address"203 raise SSRFBlockedError(msg) from exc204205 if isinstance(addr, ipaddress.IPv6Address):206 inner = _extract_embedded_ipv4(addr)207 if inner is not None:208 addr = inner209210 reason = _ip_in_blocked_networks(addr, policy)211 if reason is not None:212 raise SSRFBlockedError(reason)213214215def validate_hostname(hostname: str, policy: SSRFPolicy) -> None:216 """Validate a hostname against the SSRF policy.217218 Raises SSRFBlockedError if the hostname is blocked.219 """220 lower = hostname.lower()221222 if policy.block_localhost and lower in _LOCALHOST_NAMES:223 msg = "localhost address"224 raise SSRFBlockedError(msg)225226 if policy.block_cloud_metadata and lower in _CLOUD_METADATA_HOSTNAMES:227 msg = "cloud metadata endpoint"228 raise SSRFBlockedError(msg)229230 if policy.block_k8s_internal and lower.endswith(_K8S_SUFFIX):231 msg = "Kubernetes internal DNS"232 raise SSRFBlockedError(msg)233234235def _effective_allowed_hosts(policy: SSRFPolicy) -> frozenset[str]:236 """Return allowed_hosts, augmented for local environments."""237 extra: set[str] = set()238 if os.environ.get("LANGCHAIN_ENV", "").startswith("local"):239 extra.update({"localhost", "testserver"})240 if extra:241 return policy.allowed_hosts | frozenset(extra)242 return policy.allowed_hosts243244245async def validate_url(url: str, policy: SSRFPolicy = DEFAULT_SSRF_POLICY) -> None:246 """Validate a URL against the SSRF policy, including DNS resolution.247248 This is the primary entry-point for async code paths. It delegates249 scheme/hostname/allowed-hosts checks to `validate_url_sync`, then250 resolves DNS and validates every resolved IP.251252 Raises:253 SSRFBlockedError: If the URL violates the policy.254 """255 parsed = urllib.parse.urlparse(url)256 hostname = parsed.hostname or ""257258 validate_url_sync(url, policy)259260 allowed = {h.lower() for h in _effective_allowed_hosts(policy)}261 if hostname.lower() in allowed:262 return263264 scheme = (parsed.scheme or "").lower()265 port = parsed.port or (443 if scheme == "https" else 80)266 try:267 addrinfo = await asyncio.to_thread(268 socket.getaddrinfo, hostname, port, type=socket.SOCK_STREAM269 )270 except socket.gaierror as exc:271 msg = "DNS resolution failed"272 raise SSRFBlockedError(msg) from exc273274 for _family, _type, _proto, _canonname, sockaddr in addrinfo:275 validate_resolved_ip(str(sockaddr[0]), policy)276277278def validate_url_sync(url: str, policy: SSRFPolicy = DEFAULT_SSRF_POLICY) -> None:279 """Synchronous URL validation (no DNS resolution).280281 Suitable for Pydantic validators and other sync contexts. Checks scheme282 and hostname patterns only - use `validate_url` for full DNS-aware checking.283284 Raises:285 SSRFBlockedError: If the URL violates the policy.286 """287 parsed = urllib.parse.urlparse(url)288289 scheme = (parsed.scheme or "").lower()290 if scheme not in policy.allowed_schemes:291 msg = f"scheme '{scheme}' not allowed"292 raise SSRFBlockedError(msg)293294 hostname = parsed.hostname295 if not hostname:296 msg = "missing hostname"297 raise SSRFBlockedError(msg)298299 allowed = _effective_allowed_hosts(policy)300 if hostname.lower() in {h.lower() for h in allowed}:301 return302303 try:304 ipaddress.ip_address(hostname)305 validate_resolved_ip(hostname, policy)306 except SSRFBlockedError:307 raise308 except ValueError:309 pass310 else:311 return312313 validate_hostname(hostname, policy)
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.