Source code for klea_utils.mcp.tool_impls.ssrf

#!/usr/bin/env python3
"""
SSRF (Server-Side Request Forgery) protection for outbound HTTP tools.

File: klea_utils/mcp/tool_impls/ssrf.py

Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""

import asyncio
import ipaddress
import logging
import socket
from urllib.parse import urlparse

logger = logging.getLogger(__name__)

# Maximum time to wait for DNS resolution when called from async context
_SSRF_DNS_TIMEOUT = 5.0

#: Maximum redirects followed by ``web_fetch``/``download_file`` (per-hop SSRF-checked)
_MAX_REDIRECTS = 5


[docs] def is_private_or_reserved(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: """Return True for addresses an SSRF guard should refuse to fetch. Blocks loopback, private (RFC1918/ULA), link-local (incl. the cloud metadata address 169.254.169.254), reserved, and multicast ranges. :param ip: Address to classify. """ return ( ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_reserved or ip.is_multicast )
[docs] def check_ssrf(url: str) -> str | None: """Return an error message if *url* resolves to a private/internal host. Resolves the hostname and rejects the request when any resolved address is private, loopback, link-local, reserved, or multicast. Returns ``None`` when the request is allowed. .. note:: This is the synchronous, blocking variant (uses ``socket.getaddrinfo`` directly). Call :func:`check_ssrf_async` from async code to avoid stalling the event loop. :param url: Absolute URL to check. :returns: An error message describing the denial, or ``None`` when the URL is allowed. """ host = urlparse(url).hostname if not host: return "URL has no host." try: infos = socket.getaddrinfo(host, None) except socket.gaierror as exc: logger.warning(f"Could not resolve host {host}: {exc}") return f"Could not resolve host {host}: {exc}" logger.debug(f"Resolved {host} -> {[info[4][0] for info in infos]}") for info in infos: ip = ipaddress.ip_address(info[4][0]) if is_private_or_reserved(ip): logger.warning(f"SSRF guard: {host} resolves to {ip} (blocked)") return f"Blocked request to private/internal address: {ip}" return None
[docs] async def check_ssrf_async(url: str, timeout: float = _SSRF_DNS_TIMEOUT) -> str | None: """Async wrapper around :func:`check_ssrf` that offloads DNS to a thread. ``socket.getaddrinfo`` is blocking; running it in ``asyncio.to_thread`` keeps the event loop responsive and adds a timeout. :param url: Absolute URL to check. :param timeout: Seconds to wait for DNS before returning a timeout error. :returns: Error message or ``None`` when allowed. """ try: return await asyncio.wait_for( asyncio.to_thread(check_ssrf, url), timeout=timeout, ) except asyncio.TimeoutError: host = urlparse(url).hostname or url logger.warning(f"SSRF DNS timeout for {host}") return f"DNS timeout for {host}"