"""SSRF-guarded outbound HTTP for autodiscovery (SPEC §10.6). All autodiscovery HTTP is HTTPS-only, 5 s timeout, response capped at 256 KB, no off-domain redirects, and the target host must resolve exclusively to public IPs (reject private/loopback/link-local/reserved ranges). The request then goes to the very address that was checked — the name is not looked up a second time — so a DNS answer that changes in between (DNS rebinding) cannot point it inward. TLS is still verified against the host name. """ from __future__ import annotations import asyncio import ipaddress import socket from dataclasses import dataclass from urllib.parse import urlsplit import httpx HTTP_TIMEOUT = 5.0 MAX_RESPONSE_BYTES = 256 * 1024 class SsrfError(Exception): """Raised when a URL fails the SSRF policy (bad scheme, private IP, oversize).""" def _is_public_ip(ip: str) -> bool: addr = ipaddress.ip_address(ip.split("%", 1)[0]) # drop an IPv6 zone index if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None: addr = addr.ipv4_mapped # ::ffff:127.0.0.1 is 127.0.0.1 # is_global: not private, loopback, link-local, reserved, unspecified, nor # shared/carrier-grade NAT (100.64.0.0/10, which is_private misses). return addr.is_global and not addr.is_multicast async def _resolve_all(host: str) -> list[str]: loop = asyncio.get_running_loop() infos = await loop.getaddrinfo(host, None, proto=socket.IPPROTO_TCP) return [info[4][0] for info in infos] async def assert_host_is_public(host: str) -> list[str]: """Resolve ``host`` and reject if any address is non-public (SPEC §10.6). Returns the addresses checked, for the caller to connect to.""" try: addresses = await _resolve_all(host) except socket.gaierror as exc: raise SsrfError(f"cannot resolve {host}") from exc if not addresses: raise SsrfError(f"no addresses for {host}") for ip in addresses: if not _is_public_ip(ip): raise SsrfError(f"{host} resolves to non-public address {ip}") return addresses async def _pinned(url: str) -> tuple[str, dict[str, str], dict[str, str]]: """``url`` aimed at the address just checked: (url, headers, extensions). The URL names the IP, the Host header keeps the name (virtual hosting), and TLS gets the name for SNI and for checking the certificate.""" parts = urlsplit(url) if parts.scheme != "https": raise SsrfError("only https is permitted") hostname = parts.hostname if not hostname: raise SsrfError("missing host") addresses = await assert_host_is_public(hostname) # IPv4 first: containers often have no IPv6 route, though DNS returns AAAA. ip = next((a for a in addresses if ":" not in a), addresses[0]) literal = f"[{ip}]" if ":" in ip else ip netloc = f"{literal}:{parts.port}" if parts.port else literal host_header = f"{hostname}:{parts.port}" if parts.port else hostname return ( parts._replace(netloc=netloc).geturl(), {"Host": host_header}, {"sni_hostname": hostname}, ) async def guarded_get(url: str) -> str | None: """HTTPS GET with the full SSRF policy applied. Returns the body text on 200, or ``None`` when the resource is absent (any non-200 response). Raises :class:`SsrfError` on a policy violation (bad scheme, private IP, oversize); lets httpx network errors propagate. """ target, pin_headers, extensions = await _pinned(url) async with ( httpx.AsyncClient( follow_redirects=False, # off-domain redirects are forbidden (§10.6) timeout=HTTP_TIMEOUT, headers={"User-Agent": "webmail-autodiscovery/1.0"}, ) as client, client.stream("GET", target, headers=pin_headers, extensions=extensions) as resp, ): if resp.status_code != 200: return None chunks: list[bytes] = [] total = 0 async for chunk in resp.aiter_bytes(): total += len(chunk) if total > MAX_RESPONSE_BYTES: raise SsrfError("response exceeds 256 KB cap") chunks.append(chunk) return b"".join(chunks).decode("utf-8", "replace") @dataclass(frozen=True) class GuardedResponse: status: int headers: dict[str, str] content: bytes text: str async def guarded_request( method: str, url: str, *, headers: dict[str, str] | None = None, content: bytes | str | None = None, auth: tuple[str, str] | None = None, timeout: float = 15.0, # noqa: ASYNC109 - passed straight to httpx's own timeout max_bytes: int = 25 * 1024 * 1024, ) -> GuardedResponse: """Arbitrary-method HTTPS request under the same SSRF policy as :func:`guarded_get`. Used by plugins that speak WebDAV/CalDAV/CardDAV (PROPFIND/REPORT/PUT/DELETE) or fetch ICS feeds. HTTPS-only, public host required, no off-domain redirects, body capped at ``max_bytes``. ``auth`` is an (user, password) basic-auth pair. Returns status + headers + body (all statuses; the caller decides what's an error).""" target, pin_headers, extensions = await _pinned(url) async with ( httpx.AsyncClient(follow_redirects=False, timeout=timeout, auth=auth) as client, client.stream( method, target, headers={**(headers or {}), **pin_headers}, content=content, extensions=extensions, ) as resp, ): chunks: list[bytes] = [] total = 0 async for chunk in resp.aiter_bytes(): total += len(chunk) if total > max_bytes: raise SsrfError("response exceeds size cap") chunks.append(chunk) body = b"".join(chunks) return GuardedResponse( status=resp.status_code, headers=dict(resp.headers), content=body, text=body.decode("utf-8", "replace"), )