Files
odysseus/src/url_safety.py
T
Joeseph Grey b3f8b77317 fix(url-safety): reject RFC 6598 shared address space in strict mode (#5474)
* security(url-safety): reject RFC 6598 shared address space in strict mode

Strict mode (block_private=True) is a full SSRF lockdown, but it only
rejected is_private and is_loopback targets. CPython does not classify RFC
6598 shared/CGNAT space (100.64.0.0/10) as is_private (it is "shared", not
"private"), so a public redirect into 100.64.0.1 passed the per-hop guard
and still issued the request to a potentially internal CGNAT service.

not is_global would also exclude it, but only on CPython 3.11.10+/3.12.4+/
3.13+; the CI matrix runs 3.11/3.12, so reject the range explicitly to stay
correct across patch levels and the 3.14 runtime image. Default local-first
mode is unchanged. Adds strict-mode coverage for shared, non-global, and
public targets.

* docs(url-safety): correct CGNAT is_global rationale in strict-mode comment

The prior comment claimed `not is_global` catches 100.64.0.0/10 only on
CPython 3.11.10+/3.12.4+/3.13+. That is inaccurate for CGNAT: is_global
is False for 100.64.0.1 on every supported version (verified 3.10-3.14).
The version-fragility applies to other ranges gh-113171 touched, not CGNAT.
The explicit range reject is still the right choice; restate the reason as
is_private not covering shared space, and not coupling strict mode to
is_global's broader, cross-version definition. No behavior change.
2026-07-18 12:36:27 -06:00

109 lines
4.2 KiB
Python

"""Outbound URL safety checks (SSRF hardening).
Run before the server makes a request to a *user-supplied* URL — e.g. the custom
embedding endpoint set via ``POST /api/embeddings/endpoint``, which then triggers
an outbound ``httpx`` call.
Odysseus is local-first: pointing the embedding endpoint at a loopback or LAN
address (a local vLLM / llama.cpp / Ollama server) is a normal, intended setup.
So this guard does **not** blanket-block private addresses by default — that would
break the primary use case. What it *always* rejects:
- a non-HTTP(S) scheme (``file://``, ``gopher://``, ``ftp://`` …), and
- the link-local range (``169.254.0.0/16`` / ``fe80::/10``), i.e. the cloud
instance-metadata SSRF credential-exfil vector — nobody serves embeddings
there — plus multicast / reserved / unspecified addresses.
For exposed multi-tenant deployments, set ``EMBEDDING_BLOCK_PRIVATE_IPS=true`` to
additionally reject all private and loopback targets (full SSRF lockdown).
"""
import ipaddress
import socket
from typing import Callable, List, Optional, Tuple
from urllib.parse import urlparse
ALLOWED_SCHEMES = ("http", "https")
# RFC 6598 shared address space (carrier-grade NAT). It is not globally
# routable, but CPython does not classify it as ``is_private`` (it is "shared",
# not "private"), so the is_private/is_loopback checks miss it. Reject the range
# explicitly. This closes exactly the shared-space gap without coupling strict
# mode to ``is_global``'s broader definition, which has shifted across CPython
# versions for other special ranges.
_SHARED_ADDRESS_SPACE_V4 = ipaddress.ip_network("100.64.0.0/10")
def _default_resolver(host: str) -> List[str]:
"""Resolve a hostname to the list of IP strings it maps to (A + AAAA)."""
return [info[4][0] for info in socket.getaddrinfo(host, None)]
def _classify(ip: ipaddress._BaseAddress, *, block_private: bool) -> Optional[str]:
"""Return a rejection reason for an IP, or None if it is allowed."""
# IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) — judge the embedded v4.
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
ip = ip.ipv4_mapped
if ip.is_link_local:
return f"link-local address blocked (SSRF metadata risk): {ip}"
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
return f"disallowed address: {ip}"
if block_private and (
ip.is_private
or ip.is_loopback
or (isinstance(ip, ipaddress.IPv4Address) and ip in _SHARED_ADDRESS_SPACE_V4)
):
return f"private/shared/loopback address blocked: {ip}"
return None
def check_outbound_url(
url: str,
*,
block_private: bool = False,
resolver: Optional[Callable[[str], List[str]]] = None,
) -> Tuple[bool, str]:
"""Validate a user-supplied outbound URL.
Returns ``(ok, reason)``. ``ok`` is True only when the URL is safe to fetch.
``resolver`` is injectable so callers/tests can avoid real DNS.
"""
if not isinstance(url, str):
return False, "URL must be a string"
if not url or not url.strip():
return False, "URL is required"
try:
parsed = urlparse(url.strip())
except Exception as e: # pragma: no cover - urlparse is very tolerant
return False, f"unparseable URL: {e}"
if parsed.scheme.lower() not in ALLOWED_SCHEMES:
return False, f"scheme must be http or https, got '{parsed.scheme or '(none)'}'"
host = parsed.hostname
if not host:
return False, "URL has no host"
resolve = resolver or _default_resolver
try:
raw_ips = resolve(host)
except Exception as e:
return False, f"host does not resolve: {e}"
if not raw_ips:
return False, "host does not resolve"
saw_ip = False
for raw in raw_ips:
if not isinstance(raw, str):
continue
try:
ip = ipaddress.ip_address(raw.split("%")[0]) # strip IPv6 zone id
except ValueError:
continue
saw_ip = True
reason = _classify(ip, block_private=block_private)
if reason:
return False, reason
if not saw_ip:
return False, "host does not resolve to an IP"
return True, "ok"