6Network-egress policy (M8.0 WU-4; §5.1.3, F2/F2a).
10A shared SSRF guard for every in-process network sink. The destination host is
11resolved and every A/AAAA result classified; non-public classes are refused --
12loopback, RFC1918 / IPv6 ULA (private), link-local (incl. the cloud-metadata
13address 169.254.169.254 and IMDSv6), multicast, reserved, and unspecified.
14URLs are ``https``-only by default and embedded credentials are refused. Callers
15re-validate each redirect hop (``follow_redirects=False``).
17Closed vector: a literal-internal-IP or DNS-resolves-to-internal target (the
18common SSRF -- IMDS, loopback services, LAN hosts). Documented residual: a
19DNS-rebinding flip between this validation and the client's own connect-time
20re-resolution; true socket-pinning / the cross-process broker close that, and the
21provider channel (§5.1.5) reuses :func:`is_blocked_ip` for its own classification.
25- :func:`is_blocked_ip` -- classify one address: ``True`` unless it is a
26 globally-routable public address (an IPv4-mapped IPv6 form is unwrapped first).
27- :func:`validate_url_egress` -- the per-hop URL check for ``fetch_url``: scheme,
28 embedded credentials, host resolution, and refusal if any resolved address is
30- :func:`is_forbidden_provider_host` -- the provider-endpoint check (F72): flag an
31 IP-literal host in the link-local, multicast or reserved classes.
32- :data:`MAX_REDIRECTS` -- the redirect-hop ceiling ``fetch_url`` follows.
33- :class:`EgressDenied` -- the exception every refusal raises.
37- Fail closed: an unparseable address string counts as blocked; a non-``https``
38 scheme, embedded credentials, a missing host or an unresolvable host raises
39 :class:`EgressDenied`.
40- :func:`validate_url_egress` refuses when ANY resolved A/AAAA address is blocked,
41 and returns the validated host otherwise. It checks one hop; the caller disables
42 automatic redirects and calls it again for every redirect target.
43- :func:`is_forbidden_provider_host` never resolves DNS: loopback, RFC1918 and
44 public IP literals are not forbidden (the F72c operator-trusted endpoint
45 posture), and a bare hostname is not flagged (a hostname resolving to an
46 internal address is the M8.3 endpoint-authority residual).
52 L2 -- a refused destination: the source label, host and blocked addresses
53 L3 -- (none) allowed destinations are not logged per call
58- oc_diagnostics (structured debug logging via _dbg of refused destinations)
60from __future__
import annotations
64from urllib.parse
import urlsplit
66from oc_diagnostics
import _dbg
72_ALLOWED_SCHEMES = frozenset({
"https"})
76 """Raised when a network destination is refused by egress policy."""
79def is_blocked_ip(ip:
"str | ipaddress.IPv4Address | ipaddress.IPv6Address") -> bool:
81 """Return ``True`` if *ip* is NOT a globally-routable public address.
83 Refuses loopback / private (RFC1918 + IPv6 ULA) / link-local (incl. IMDS
84 ``169.254.169.254`` and IMDSv6) / multicast / reserved / unspecified, and the
85 IPv4-mapped IPv6 form (``::ffff:127.0.0.1``) of any of those. An unparseable
86 value is refused (fail-closed).
89 addr = ipaddress.ip_address(ip)
if isinstance(ip, str)
else ip
92 if isinstance(addr, ipaddress.IPv6Address)
and addr.ipv4_mapped
is not None:
93 addr = addr.ipv4_mapped
95 addr.is_loopback
or addr.is_private
or addr.is_link_local
96 or addr.is_multicast
or addr.is_reserved
or addr.is_unspecified
102 """Resolve *host* to all A/AAAA addresses (may raise :class:`socket.gaierror`)."""
103 infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
104 return [info[4][0]
for info
in infos]
108 """Validate *url* for outbound egress; raise :class:`EgressDenied` if refused.
110 Enforces the ``https``-only scheme, refuses embedded credentials, resolves the
111 host, and refuses if ANY resolved IP is in a blocked class. Returns the
112 validated host. Call once per redirect hop.
114 func =
"validate_url_egress"
115 parts = urlsplit(url)
116 scheme = (parts.scheme
or "").lower()
117 if scheme
not in _ALLOWED_SCHEMES:
118 raise EgressDenied(f
"scheme {scheme!r} not allowed (https only): {url}")
119 if parts.username
or parts.password:
121 host = parts.hostname
124 port = parts.port
or 443
127 except socket.gaierror
as exc:
128 raise EgressDenied(f
"cannot resolve host {host!r}: {exc}")
from exc
131 _dbg(
"OC_CHAT.CORE", func,
132 f
"{source_label}: blocked {host} -> {blocked}", 2)
134 f
"host {host!r} resolves to a non-public address "
135 f
"(loopback/private/link-local/metadata); refused"
141 """Return ``True`` if *host_or_url* is an IP literal that is never a
142 legitimate provider endpoint (M8.0 WU-4c; F72).
144 Forbidden = link-local (incl. cloud-metadata ``169.254.169.254`` and IMDSv6),
145 multicast, or reserved. Loopback (an Ollama on localhost), RFC1918 (a LAN
146 Ollama box), and public hosts (cloud APIs) are NOT forbidden -- the
147 cloud-API-normal posture (F72c) allows operator-trusted endpoints of any of
148 those classes. Bare hostnames are not flagged here: the closed vector is the
149 literal-IP injection (``DEEPSEEK_BASE_URL=http://169.254.169.254``); a
150 hostname that resolves to an internal address is the M8.3 endpoint-authority
151 residual (the remote-requires-OOB-authorization gate).
153 host = urlsplit(host_or_url).hostname
if "://" in host_or_url
else host_or_url
154 host = (host
or "").strip().strip(
"[]")
156 addr = ipaddress.ip_address(host)
159 if isinstance(addr, ipaddress.IPv6Address)
and addr.ipv4_mapped
is not None:
160 addr = addr.ipv4_mapped
161 return addr.is_link_local
or addr.is_multicast
or addr.is_reserved
165 "EgressDenied",
"MAX_REDIRECTS",
"is_blocked_ip",
166 "is_forbidden_provider_host",
"validate_url_egress",
list[str] _resolved_ips(str host, int port)
bool is_blocked_ip("str | ipaddress.IPv4Address | ipaddress.IPv6Address" ip)
str validate_url_egress(str url, *, str source_label="fetch_url")
bool is_forbidden_provider_host(str host_or_url)