Source code for kavalai.net

"""
Copyright 2026 OÜ KAVAL AI (registry code 17393877)

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

Outbound requests to URLs a model chooses: the guard against SSRF.

A bundled web tool fetches whatever URL a model composes, and a model can be
talked into composing ``http://169.254.169.254/`` or ``http://localhost:5432/``.
This module refuses such targets.

- :func:`is_public_address` decides whether one IP address is globally
  routable unicast.
- :func:`ensure_public_url` checks a URL: scheme, authority, host name, and
  every address the host resolves to. It is a *pre-check* — by itself it is
  defeated by DNS rebinding, because the client that fetches the URL resolves
  the name a second time and may receive a different answer.
- :class:`PublicOnlyTransport` closes that gap for httpx. Its network backend
  resolves the host, checks every address and then connects to the address it
  checked, so no second lookup takes place. TLS still verifies the certificate
  for the host *name* and sends it as SNI, and the ``Host`` header is the one in
  the URL. Each redirect hop is a new request through the same transport, so it
  is checked in the same way.

The module is not imported by ``kavalai/__init__.py``: it needs httpx, and
``import kavalai`` has to work under Pyodide without it.
"""

import asyncio
import ipaddress
import socket
import typing
from collections.abc import Awaitable, Callable, Iterable
from urllib.parse import SplitResult, urlsplit

import httpcore
import httpx

IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address

Resolver = Callable[[str], Awaitable[list[str]]]
"""An ASCII host name to the IP addresses it resolves to, in preference order.

An empty list means the name does not resolve. :func:`resolve_host` is the
default; tests pass their own.
"""

METADATA_ADDRESSES = frozenset(
    ipaddress.ip_address(address)
    for address in (
        "169.254.169.254",
        "169.254.170.2",
        "168.63.129.16",
        "100.100.100.200",
        "fd00:ec2::254",
    )
)
"""Cloud metadata and platform endpoints.

AWS, GCP, Azure, Oracle and DigitalOcean serve instance metadata at
``169.254.169.254``, ECS task credentials at ``169.254.170.2``, Alibaba at
``100.100.100.200`` and AWS over IPv6 at ``fd00:ec2::254``. All of these fall
in ranges refused anyway; they are listed so the refusal does not depend on the
interpreter's address tables. Azure's platform endpoint ``168.63.129.16`` is a
*global* address, so without this entry it would be allowed.
"""

BLOCKED_NETWORKS = tuple(
    ipaddress.ip_network(network)
    for network in (
        "0.0.0.0/8",
        "100.64.0.0/10",
        "192.0.0.0/24",
        "198.18.0.0/15",
        "fec0::/10",
        "3fff::/20",
        "5f00::/16",
    )
)
"""Networks refused in addition to what :mod:`ipaddress` calls non-global.

``0.0.0.0/8`` (reaches the local host on Linux), carrier-grade NAT
``100.64.0.0/10``, IETF protocol assignments ``192.0.0.0/24``, benchmarking
``198.18.0.0/15``, deprecated IPv6 site-local ``fec0::/10`` (global to Python
3.12), IPv6 documentation ``3fff::/20`` (RFC 9637, global to Python 3.12) and
SRv6 segment identifiers ``5f00::/16``.
"""

NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
IPV4_COMPATIBLE_PREFIX = ipaddress.ip_network("::/96")

BLOCKED_HOSTNAMES = frozenset({"localhost", "metadata"})
BLOCKED_SUFFIXES = (".localhost", ".internal", ".local")


[docs] class UnsafeUrlError(ValueError): """A URL, or an address its host resolves to, is not a public target."""
def _embedded_ipv4(ip: ipaddress.IPv6Address) -> list[ipaddress.IPv4Address]: """The IPv4 addresses an IPv6 address carries, for the forms that tunnel. IPv4-compatible (``::a.b.c.d``), 6to4 (``2002::/16``) and Teredo (``2001::/32``, server and client). IPv4-mapped and NAT64 addresses are handled by :func:`is_public_address` directly, since they *are* the IPv4 host rather than a tunnel to it. """ embedded = [] if ip in IPV4_COMPATIBLE_PREFIX: embedded.append(ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)) if ip.sixtofour is not None: embedded.append(ip.sixtofour) if ip.teredo is not None: embedded.extend(ip.teredo) return embedded
[docs] def is_public_address(address: str | IPAddress) -> bool: """Whether ``address`` is globally routable unicast. Refuses loopback, private, link-local, multicast, reserved, unspecified, carrier-grade NAT, benchmarking and documentation ranges, ``0.0.0.0/8`` and the cloud metadata endpoints in :data:`METADATA_ADDRESSES`. An IPv4-mapped address (``::ffff:a.b.c.d``) and a NAT64 address (``64:ff9b::a.b.c.d``) are judged as the IPv4 address they reach. An IPv6 address that tunnels to an IPv4 one — IPv4-compatible, 6to4, Teredo — is refused when that IPv4 address is not public, and is otherwise judged as an IPv6 address. Args: address: An address as text or as an :mod:`ipaddress` object. A zone index (``fe80::1%eth0``) is ignored. Raises: ValueError: ``address`` is not an IP address. """ ip = _as_address(address) if isinstance(ip, ipaddress.IPv6Address): if ip.ipv4_mapped is not None: return is_public_address(ip.ipv4_mapped) if ip in NAT64_PREFIX: return is_public_address(ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)) if not all(is_public_address(inner) for inner in _embedded_ipv4(ip)): return False if ip.is_site_local: return False if ip in METADATA_ADDRESSES or any(ip in net for net in BLOCKED_NETWORKS): return False return ip.is_global and not (ip.is_multicast or ip.is_reserved or ip.is_unspecified)
def _as_address(address: str | IPAddress) -> IPAddress: if isinstance(address, (ipaddress.IPv4Address, ipaddress.IPv6Address)): address = str(address) return ipaddress.ip_address(address.split("%", 1)[0]) def _inet_aton_part(part: str) -> int | None: """One dotted part in the forms ``inet_aton`` accepts: 0x hex, 0 octal, decimal.""" if part[:2].lower() == "0x": digits, base, alphabet = part[2:], 16, "0123456789abcdefABCDEF" elif len(part) > 1 and part[0] == "0": digits, base, alphabet = part[1:], 8, "01234567" else: digits, base, alphabet = part, 10, "0123456789" if not digits or any(c not in alphabet for c in digits): return None return int(digits, base) def _parse_inet_aton(host: str) -> ipaddress.IPv4Address | None: """``host`` read the way the C library reads a numeric IPv4 host. ``127.0.0.1``, ``2130706433``, ``0x7f.1``, ``0177.0.0.01`` and ``127.1`` all name the loopback address: one to four parts, each decimal, octal or hex, the last part filling the remaining bytes. """ parts = host.split(".") if len(parts) > 4: return None values = [_inet_aton_part(part) for part in parts] if any(value is None for value in values): return None *leading, last = values if any(value > 0xFF for value in leading) or last >= 256 ** (4 - len(leading)): return None number = last for position, value in enumerate(leading): number |= value << (8 * (3 - position)) return ipaddress.IPv4Address(number)
[docs] def parse_ip_literal(host: str) -> IPAddress | None: """The address ``host`` spells out, or ``None`` when it is a name. Accepts IPv6 with or without brackets and IPv4 in every form the C library's ``inet_aton`` accepts: dotted, decimal, octal, hex and the short forms (``127.1``). One trailing dot is ignored. """ if host.startswith("[") and host.endswith("]"): host = host[1:-1] elif host.endswith("."): host = host[:-1] if ":" in host: try: return _as_address(host) except ValueError: return None return _parse_inet_aton(host) if host else None
[docs] async def resolve_host(host: str) -> list[str]: """``host``'s addresses from the system resolver, in its preference order. Returns an empty list when the name does not resolve. """ loop = asyncio.get_running_loop() try: infos = await loop.getaddrinfo(host, None, type=socket.SOCK_STREAM) except OSError: return [] return list(dict.fromkeys(str(info[4][0]) for info in infos))
def _ascii_host(host: str) -> str: """``host`` lower-cased and, when it is not ASCII, IDNA-encoded. The encoding applies UTS #46 mapping, as a browser does, so a fullwidth ``localhost`` becomes ``localhost`` before the name checks run. """ import idna host = host.rstrip(".") if host.isascii(): return host.lower() try: return idna.encode(host, uts46=True).decode("ascii") except idna.IDNAError as error: raise UnsafeUrlError(f"{host!r} is not a valid host name: {error}") from error def _check_host_name(host: str) -> None: first_label = host.split(".", 1)[0] if ( host in BLOCKED_HOSTNAMES or first_label == "metadata" or host.endswith(BLOCKED_SUFFIXES) ): raise UnsafeUrlError(f"{host} is an internal host name") def _split_url(url: str) -> SplitResult: """``url`` split, with everything refused that does not name a host plainly. ``urlsplit`` accepts an out-of-range port and raises only when ``port`` is read, so it is read here. """ if any(ord(c) < 0x21 or ord(c) == 0x7F for c in url): raise UnsafeUrlError("the URL contains whitespace or control characters") try: parts = urlsplit(url) _ = parts.port except ValueError as error: raise UnsafeUrlError(f"{url!r} is not a valid URL: {error}") from error if parts.scheme.lower() not in ("http", "https"): raise UnsafeUrlError(f"scheme {parts.scheme!r} is not allowed") if "@" in parts.netloc: raise UnsafeUrlError("credentials in the URL are not allowed") if "\\" in parts.netloc: raise UnsafeUrlError("a backslash in the host is not allowed") if not parts.hostname: raise UnsafeUrlError(f"{url!r} has no host") return parts async def _vetted_addresses(host: str, resolver: Resolver) -> list[str]: """Every address ``host`` stands for, each one checked. Raises: UnsafeUrlError: An address is not public. """ literal = parse_ip_literal(host) if literal is not None: address = str(literal) if not is_public_address(address): spelled = "" if host == address else f" ({host})" raise UnsafeUrlError(f"{address}{spelled} is a non-public address") return [address] addresses = await resolver(host) for address in addresses: if not is_public_address(address): raise UnsafeUrlError(f"{host} resolves to non-public address {address}") return addresses
[docs] async def ensure_public_url(url: str, *, resolver: Resolver | None = None) -> str: """Return ``url`` unchanged when it points at a public host; raise otherwise. Refuses a scheme other than ``http``/``https``; credentials, a backslash or whitespace in the URL, which different URL parsers read differently; ``localhost`` and ``*.localhost``; a host whose first label is ``metadata``; ``*.internal`` and ``*.local``; and a host with any address for which :func:`is_public_address` is false. IP literals are read in every encoding (``http://2130706433/`` is ``127.0.0.1``) and never sent to the resolver; non-ASCII names are IDNA-encoded first. This is a pre-check: the client that fetches the URL resolves the name again. Use :class:`PublicOnlyTransport` where the connection itself is under the SDK's control. Args: url: The absolute URL to check. resolver: Resolves a host name; :func:`resolve_host` by default. Raises: UnsafeUrlError: The URL or one of its addresses is not public, or the host does not resolve. """ parts = _split_url(url) host = _ascii_host(parts.hostname) _check_host_name(host) addresses = await _vetted_addresses(host, resolver or resolve_host) if not addresses: raise UnsafeUrlError(f"{host} does not resolve") return url
class _PinnedNetworkBackend(httpcore.AsyncNetworkBackend): """An httpcore network backend that dials only addresses it has checked. httpcore hands :meth:`connect_tcp` the host name from the URL; TLS and the ``Host`` header are handled above this layer with that same name, so replacing the name with a vetted address here changes only where the socket goes. """ def __init__(self, resolver: Resolver, backend: httpcore.AsyncNetworkBackend): self._resolver = resolver self._backend = backend async def connect_tcp( self, host: str, port: int, timeout: float | None = None, local_address: str | None = None, socket_options: Iterable[typing.Any] | None = None, ) -> httpcore.AsyncNetworkStream: name = _ascii_host(host) _check_host_name(name) addresses = await _vetted_addresses(name, self._resolver) if not addresses: raise httpcore.ConnectError(f"{name} does not resolve") error: Exception | None = None for address in addresses: try: return await self._backend.connect_tcp( address, port, timeout=timeout, local_address=local_address, socket_options=socket_options, ) except (httpcore.ConnectError, httpcore.ConnectTimeout) as failure: error = failure raise error async def sleep(self, seconds: float) -> None: await self._backend.sleep(seconds)
[docs] class PublicOnlyTransport(httpx.AsyncHTTPTransport): """An httpx transport that connects only to public addresses. Every connection resolves its host once, refuses it unless every address is public (:func:`is_public_address`), and dials the address it checked, so a DNS answer that changes between the check and the connection cannot redirect it. The certificate is still verified against the host name, which is also sent as SNI. Redirects are followed by httpx as new requests through this transport, so each hop is checked. A refusal raises :class:`UnsafeUrlError` from the request. ``verify``, ``cert``, ``trust_env``, ``http1``, ``http2``, ``limits`` and ``retries`` mean what they mean for :class:`httpx.AsyncHTTPTransport`. There is no ``proxy``: through a proxy the proxy resolves the name, so the guard could not apply. The constructor builds its own connection pool rather than calling the parent's, because that one offers no way to choose the network backend. Args: resolver: Resolves a host name; :func:`resolve_host` by default. network_backend: The httpcore backend that opens the socket to the vetted address; :class:`httpcore.AnyIOBackend` by default. """ def __init__( self, *, resolver: Resolver | None = None, network_backend: httpcore.AsyncNetworkBackend | None = None, verify: typing.Any = True, cert: typing.Any = None, trust_env: bool = True, http1: bool = True, http2: bool = False, limits: httpx.Limits | None = None, retries: int = 0, ) -> None: limits = limits or httpx.Limits( max_connections=100, max_keepalive_connections=20 ) self._pool = httpcore.AsyncConnectionPool( ssl_context=httpx.create_ssl_context( verify=verify, cert=cert, trust_env=trust_env ), max_connections=limits.max_connections, max_keepalive_connections=limits.max_keepalive_connections, keepalive_expiry=limits.keepalive_expiry, http1=http1, http2=http2, retries=retries, network_backend=_PinnedNetworkBackend( resolver or resolve_host, network_backend or httpcore.AnyIOBackend() ), )