106 lines
3.4 KiB
Python
106 lines
3.4 KiB
Python
"""Egress policy for user-controlled web_fetch URLs (SSRF guard)."""
|
|
|
|
import ipaddress
|
|
import socket
|
|
from dataclasses import dataclass
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class WebFetchEgressPolicy:
|
|
"""Egress rules for user-influenced web_fetch URLs."""
|
|
|
|
allow_private_network_targets: bool
|
|
allowed_schemes: frozenset[str]
|
|
|
|
|
|
class WebFetchEgressViolation(ValueError):
|
|
"""Raised when a web_fetch URL is rejected by egress policy (SSRF guard)."""
|
|
|
|
|
|
def web_fetch_allowed_scheme_set(raw_schemes: str) -> frozenset[str]:
|
|
"""Return normalized schemes allowed for web_fetch."""
|
|
|
|
return frozenset(
|
|
part.strip().lower() for part in raw_schemes.split(",") if part.strip()
|
|
)
|
|
|
|
|
|
def _port_for_url(parsed) -> int:
|
|
if parsed.port is not None:
|
|
return parsed.port
|
|
return 443 if (parsed.scheme or "").lower() == "https" else 80
|
|
|
|
|
|
def _stream_getaddrinfo_or_raise(host: str, port: int) -> list[tuple]:
|
|
try:
|
|
return socket.getaddrinfo(
|
|
host, port, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP
|
|
)
|
|
except OSError as exc:
|
|
raise WebFetchEgressViolation(
|
|
f"Could not resolve host {host!r}: {exc}"
|
|
) from exc
|
|
|
|
|
|
def get_validated_stream_addrinfos_for_egress(
|
|
url: str, policy: WebFetchEgressPolicy
|
|
) -> list[tuple]:
|
|
"""Resolve and validate a URL for web_fetch, returning getaddrinfo rows for pinning.
|
|
|
|
Each HTTP connect pins to only these `getaddrinfo` results so a malicious DNS
|
|
server cannot rebind to a disallowed address between resolution and the TCP
|
|
connect (used by :func:`api.web_tools.outbound._run_web_fetch`).
|
|
"""
|
|
parsed = urlparse(url)
|
|
scheme = (parsed.scheme or "").lower()
|
|
if scheme not in policy.allowed_schemes:
|
|
raise WebFetchEgressViolation(
|
|
f"URL scheme {scheme!r} is not allowed for web_fetch"
|
|
)
|
|
|
|
host = parsed.hostname
|
|
if host is None or host == "":
|
|
raise WebFetchEgressViolation("web_fetch URL must include a host")
|
|
|
|
port = _port_for_url(parsed)
|
|
|
|
if policy.allow_private_network_targets:
|
|
return _stream_getaddrinfo_or_raise(host, port)
|
|
|
|
host_lower = host.lower()
|
|
if host_lower == "localhost" or host_lower.endswith(".localhost"):
|
|
raise WebFetchEgressViolation("localhost targets are not allowed for web_fetch")
|
|
if host_lower.endswith(".local"):
|
|
raise WebFetchEgressViolation(".local hostnames are not allowed for web_fetch")
|
|
|
|
try:
|
|
parsed_ip = ipaddress.ip_address(host)
|
|
except ValueError:
|
|
parsed_ip = None
|
|
|
|
if parsed_ip is not None:
|
|
if not parsed_ip.is_global:
|
|
raise WebFetchEgressViolation(
|
|
f"Non-public IP host {host!r} is not allowed for web_fetch"
|
|
)
|
|
return _stream_getaddrinfo_or_raise(host, port)
|
|
|
|
infos = _stream_getaddrinfo_or_raise(host, port)
|
|
for *_, sockaddr in infos:
|
|
addr = sockaddr[0]
|
|
try:
|
|
resolved = ipaddress.ip_address(addr)
|
|
except ValueError:
|
|
continue
|
|
if not resolved.is_global:
|
|
raise WebFetchEgressViolation(
|
|
f"Host {host!r} resolves to a non-public address ({resolved})"
|
|
)
|
|
return infos
|
|
|
|
|
|
def enforce_web_fetch_egress(url: str, policy: WebFetchEgressPolicy) -> None:
|
|
"""Validate ``url`` (scheme, host, and resolved addresses) for web_fetch."""
|
|
get_validated_stream_addrinfos_for_egress(url, policy)
|