libs/core/langchain_core/_security/_policy.py PYTHON 314 lines View on github.com → Search inside
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)

Code quality findings 6

Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(addr, ipaddress.IPv4Address):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(blocked_cidr, ipaddress.IPv4Network) and addr in blocked_cidr:
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(blocked_cidr, ipaddress.IPv6Network) and addr in blocked_cidr:
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(addr, ipaddress.IPv4Address) and (
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(addr, ipaddress.IPv6Address) and addr == _LOOPBACK_IPV6:
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(addr, ipaddress.IPv6Address):

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.