Coverage for src / lexigram / contracts / security / url_safety.py: 52%
48 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-19 05:41 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-19 05:41 +0800
1"""SSRF URL-safety primitive shared by every outbound HTTP consumer.
3Pure standard-library only (``ipaddress``, ``socket``, ``urllib.parse``) so
4that any extension package — including ``lexigram-webhook``, ``lexigram-ai-rag``
5and ``lexigram-ai-mcp``, which may not import ``lexigram.security`` — can
6consume it directly. Keep this file stdlib-only (import-linter Contract 1).
7"""
9from __future__ import annotations
11from collections.abc import Callable, Sequence
12import ipaddress
13import socket
14import urllib.parse
16_PRIVATE_IPV4_NETWORKS: tuple[ipaddress.IPv4Network, ...] = (
17 ipaddress.IPv4Network("0.0.0.0/8"),
18 ipaddress.IPv4Network("10.0.0.0/8"),
19 ipaddress.IPv4Network("100.64.0.0/10"),
20 ipaddress.IPv4Network("127.0.0.0/8"),
21 ipaddress.IPv4Network("169.254.0.0/16"),
22 ipaddress.IPv4Network("172.16.0.0/12"),
23 ipaddress.IPv4Network("192.168.0.0/16"),
24 ipaddress.IPv4Network("224.0.0.0/4"),
25 ipaddress.IPv4Network("240.0.0.0/4"),
26)
28_PRIVATE_IPV6_NETWORKS: tuple[ipaddress.IPv6Network, ...] = (
29 ipaddress.IPv6Network("::/128"),
30 ipaddress.IPv6Network("::1/128"),
31 ipaddress.IPv6Network("fc00::/7"),
32 ipaddress.IPv6Network("fe80::/10"),
33)
35HostResolver = Callable[[str], Sequence[ipaddress.IPv4Address | ipaddress.IPv6Address]]
38def resolve_hostname(
39 hostname: str,
40) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
41 """Resolve a hostname to all its address records via the system resolver.
43 Args:
44 hostname: DNS name to resolve.
46 Returns:
47 List of resolved addresses (A/AAAA records).
49 Raises:
50 OSError: If the hostname cannot be resolved.
51 """
52 infos = socket.getaddrinfo(
53 hostname, None, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP
54 )
55 addresses: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
56 for _family, _stype, _proto, _canonname, sockaddr in infos:
57 try:
58 addresses.append(ipaddress.ip_address(sockaddr[0]))
59 except ValueError:
60 continue
61 return addresses
64def is_safe_url_for_request(
65 url: str,
66 *,
67 resolver: HostResolver | None = None,
68) -> bool:
69 """Return False when requesting ``url`` could reach a private/reserved host.
71 Checks the scheme (http/https only), rejects literal private/reserved IP
72 hostnames, and — for DNS hostnames — resolves via ``resolver`` (defaults to
73 the system ``getaddrinfo``) and rejects when ANY resolved address is
74 private/reserved. Fails closed: an unresolvable or empty resolution is
75 rejected.
77 Args:
78 url: Candidate absolute URL.
79 resolver: Optional hostname resolver for hermetic tests. Defaults to
80 the system resolver via :func:`resolve_hostname`.
82 Returns:
83 True only when a request to ``url`` cannot reach a private/reserved IP.
84 """
85 try:
86 parsed = urllib.parse.urlparse(url)
87 except ValueError:
88 return False
90 if parsed.scheme.lower() not in {"http", "https"}:
91 return False
93 hostname = parsed.hostname
94 if not hostname:
95 return False
97 hostname = hostname.strip("[]")
98 try:
99 addr = ipaddress.ip_address(hostname)
100 except ValueError:
101 return _hostname_is_public(hostname, resolver)
103 return not _is_private(addr)
106def _hostname_is_public(hostname: str, resolver: HostResolver | None) -> bool:
107 try:
108 addresses = (
109 resolve_hostname(hostname) if resolver is None else resolver(hostname)
110 )
111 except OSError:
112 return False
113 if not addresses:
114 return False
115 return all(not _is_private(address) for address in addresses)
118def _is_private(
119 addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
120) -> bool:
121 if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
122 addr = addr.ipv4_mapped
123 if isinstance(addr, ipaddress.IPv4Address):
124 return any(addr in network for network in _PRIVATE_IPV4_NETWORKS)
125 return any(addr in network for network in _PRIVATE_IPV6_NETWORKS)
128__all__ = ["HostResolver", "is_safe_url_for_request", "resolve_hostname"]