"""Keep bounded acquisition bounded to the official host it was aimed at. Every acquisition service builds its URL from configured settings, so a request payload cannot point the runtime somewhere else. The redirect chain can: ``urlopen`` follows redirects by default, so a misconfigured or compromised upstream can send the runtime to the loopback interface, to another container on the compose network, or to a cloud metadata endpoint — and whatever comes back is then persisted as official source data. That is the substitution the product explicitly forbids, so a redirect that leaves the configured origin fails closed instead. """ from __future__ import annotations import ipaddress import socket from collections.abc import Callable from typing import Any from urllib.parse import urlparse from urllib.request import urlopen from app.core.errors import AppError ALLOWED_SCHEMES = {"http", "https"} def _reject(code: str, message: str, **details: Any) -> AppError: return AppError(code=code, message=message, details=details or None, status_code=502) def _resolved_addresses(host: str) -> list[str]: """Every address the host resolves to, so a DNS name cannot hide a private one.""" try: infos = socket.getaddrinfo(host, None) except OSError: # Resolution failure is not the guard's problem: the request itself will # fail with a clear provider error a moment later. return [] return [str(info[4][0]) for info in infos] def _is_public_address(value: str) -> bool: try: address = ipaddress.ip_address(value) except ValueError: return False return not ( address.is_private or address.is_loopback or address.is_link_local or address.is_reserved or address.is_multicast or address.is_unspecified ) def assert_public_http_url(url: str) -> None: """Refuse anything that is not an ordinary outbound HTTP(S) destination.""" parsed = urlparse(url) if parsed.scheme not in ALLOWED_SCHEMES: raise _reject( "OUTBOUND_URL_NOT_ALLOWED", "Bounded acquisition only performs HTTP(S) requests.", scheme=parsed.scheme, ) host = parsed.hostname if not host: raise _reject("OUTBOUND_URL_NOT_ALLOWED", "Outbound request has no host.", url=url) literal = host.strip("[]") candidates = [literal] if _looks_like_ip(literal) else _resolved_addresses(host) if candidates and not all(_is_public_address(candidate) for candidate in candidates): raise _reject( "OUTBOUND_URL_NOT_ALLOWED", "Bounded acquisition refuses a private, loopback or link-local destination.", host=host, ) def _looks_like_ip(value: str) -> bool: try: ipaddress.ip_address(value) except ValueError: return False return True def assert_same_origin_redirect(original_url: str, final_url: str) -> None: """Allow a redirect only within the origin the request was aimed at. A path change is normal — providers version their endpoints. A host change means the bytes no longer come from the source the provenance will claim, and a scheme downgrade means they are no longer protected in transit. """ if not final_url or final_url == original_url: return original = urlparse(original_url) final = urlparse(final_url) if (final.hostname or "").casefold() != (original.hostname or "").casefold(): raise _reject( "OUTBOUND_REDIRECT_NOT_ALLOWED", "The official endpoint redirected to a different host; acquisition fails closed.", expected_host=original.hostname, redirect_host=final.hostname, ) if original.scheme == "https" and final.scheme != "https": raise _reject( "OUTBOUND_REDIRECT_NOT_ALLOWED", "The official endpoint redirected from HTTPS to an unprotected scheme.", redirect_scheme=final.scheme, ) assert_public_http_url(final_url) def guarded_opener(expected_url: str) -> Callable[..., Any]: """An ``urlopen`` replacement that verifies where the response came from. ``urlopen`` has already followed the redirect chain by the time it returns, so the check is on ``response.url``: the body is still unread, and raising here means nothing off-origin is ever parsed or persisted. """ assert_public_http_url(expected_url) def _open(request: Any, *args: Any, _transport: Callable[..., Any] | None = None, **kwargs: Any) -> Any: response = (_transport or urlopen)(request, *args, **kwargs) final_url = str(getattr(response, "url", "") or "") try: assert_same_origin_redirect(expected_url, final_url) except AppError: close = getattr(response, "close", None) if callable(close): close() raise return response return _open