oc_chat
Loading...
Searching...
No Matches
egress_guard.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/egress_guard.py
4
5"""
6Network-egress policy (M8.0 WU-4; §5.1.3, F2/F2a).
7
8Purpose
9-------
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``).
16
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.
22
23Responsibilities
24----------------
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
29 blocked.
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.
34
35Contracts
36---------
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).
47
48Diagnostics
49-----------
50Domain: OC_CHAT.CORE
51Levels:
52 L2 -- a refused destination: the source label, host and blocked addresses
53 L3 -- (none) allowed destinations are not logged per call
54 L4 -- deep tracing
55
56Dependencies
57------------
58- oc_diagnostics (structured debug logging via _dbg of refused destinations)
59"""
60from __future__ import annotations
61
62import ipaddress
63import socket
64from urllib.parse import urlsplit
65
66from oc_diagnostics import _dbg
67
68#: Max redirect hops fetch_url follows (Appendix B).
69MAX_REDIRECTS = 5
70
71#: Allowed URL schemes for fetch_url (https-only by default, §5.1.3).
72_ALLOWED_SCHEMES = frozenset({"https"})
73
74
75class EgressDenied(Exception):
76 """Raised when a network destination is refused by egress policy."""
77
78
79def is_blocked_ip(ip: "str | ipaddress.IPv4Address | ipaddress.IPv6Address") -> bool:
80 # OCT-LINT: disable=no_hardcoded_secrets reason="Function docstring prose listing the refused address classes; no credential material (operator-ruled 2026-09-28)"
81 """Return ``True`` if *ip* is NOT a globally-routable public address.
82
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).
87 """
88 try:
89 addr = ipaddress.ip_address(ip) if isinstance(ip, str) else ip
90 except ValueError:
91 return True
92 if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
93 addr = addr.ipv4_mapped
94 return (
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
97 or not addr.is_global
98 )
99
100
101def _resolved_ips(host: str, port: int) -> list[str]:
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]
105
106
107def validate_url_egress(url: str, *, source_label: str = "fetch_url") -> str:
108 """Validate *url* for outbound egress; raise :class:`EgressDenied` if refused.
109
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.
113 """
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:
120 raise EgressDenied("URL credentials are not allowed")
121 host = parts.hostname
122 if not host:
123 raise EgressDenied(f"no host in URL: {url}")
124 port = parts.port or 443
125 try:
126 ips = _resolved_ips(host, port)
127 except socket.gaierror as exc:
128 raise EgressDenied(f"cannot resolve host {host!r}: {exc}") from exc
129 blocked = sorted({ip for ip in ips if is_blocked_ip(ip)})
130 if blocked:
131 _dbg("OC_CHAT.CORE", func,
132 f"{source_label}: blocked {host} -> {blocked}", 2)
133 raise EgressDenied(
134 f"host {host!r} resolves to a non-public address "
135 f"(loopback/private/link-local/metadata); refused"
136 )
137 return host
138
139
140def is_forbidden_provider_host(host_or_url: str) -> bool:
141 """Return ``True`` if *host_or_url* is an IP literal that is never a
142 legitimate provider endpoint (M8.0 WU-4c; F72).
143
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).
152 """
153 host = urlsplit(host_or_url).hostname if "://" in host_or_url else host_or_url
154 host = (host or "").strip().strip("[]")
155 try:
156 addr = ipaddress.ip_address(host)
157 except ValueError:
158 return False
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
162
163
164__all__ = [
165 "EgressDenied", "MAX_REDIRECTS", "is_blocked_ip",
166 "is_forbidden_provider_host", "validate_url_egress",
167]
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)