CoolFace
Apppublic

apodex/frontier-agent-demo

sourceHugging Faceapache-2.0updated 1mo agoView on Hugging Face
14likes
_bounded_fetch.py284 linesDownload Raw Back to tools
1"""Bounded HTTP body reads for the web_fetch tool family."""2 3from __future__ import annotations4 5import asyncio6import os7 8import httpx9 10# Byte ceiling for one response body. Env-tunable for callers that11# legitimately need bigger text payloads.12DEFAULT_MAX_FETCH_BYTES = 5 * 1024 * 102413 14 15def max_fetch_bytes() -> int:16    raw = (os.getenv("WEB_FETCH_MAX_BYTES") or "").strip()17    if raw:18        try:19            value = int(raw)20            if value > 0:21                return value22        except ValueError:23            pass24    return DEFAULT_MAX_FETCH_BYTES25 26 27# Content-types that are data blobs, never extractable page text. PDF is28# deliberately absent: Jina converts PDFs to text, so blocking it here29# would regress the primary scrape path. Prefix match on the bare type30# (parameters like ``; charset=`` stripped by the caller helper).31_BINARY_TYPE_PREFIXES = ("image/", "audio/", "video/", "font/")32_BINARY_TYPES = frozenset({33    "application/zip",34    "application/octet-stream",35    "application/x-tar",36    "application/gzip",37    "application/x-gzip",38    "application/x-bzip2",39    "application/x-xz",40    "application/x-7z-compressed",41    "application/x-rar-compressed",42    "application/x-hdf",43    "application/x-hdf5",44    "application/vnd.ms-cab-compressed",45    "application/x-matlab-data",46    "application/x-msdownload",47    "application/wasm",48})49 50 51# URL path extensions that denote dataset / archive / binary downloads.52# Screened BEFORE any request is issued (no Jina call, no bytes on the53# wire). Text-ish data formats (.csv/.json/.txt/.xml) are deliberately54# absent: small ones are legitimate fetch targets and the byte cap55# bounds the large ones. PDF stays allowed (Jina converts it).56_BLOCKED_URL_EXTENSIONS = (57    ".zip", ".tar", ".tgz", ".tar.gz", ".tar.bz2", ".tar.xz",58    ".gz", ".bz2", ".xz", ".7z", ".rar", ".zst",59    ".mat", ".h5", ".hdf5", ".npz", ".npy", ".pkl", ".pickle",60    ".pt", ".pth", ".onnx", ".safetensors", ".parquet", ".feather",61    ".whl", ".deb", ".rpm", ".dmg", ".iso", ".exe", ".msi", ".apk",62)63 64 65def blocked_download_url(url: str) -> str | None:66    """The matched extension when ``url``'s path names a dataset/archive67    download, else ``None``. Match is on the URL *path* (query/fragment68    stripped), so ``?format=zip`` params don't false-positive."""69    try:70        from urllib.parse import unquote, urlsplit71 72        path = unquote(urlsplit(url).path).strip().lower()73    except Exception:74        return None75    for ext in _BLOCKED_URL_EXTENSIONS:76        if path.endswith(ext):77            return ext78    return None79 80 81def binary_content_type(content_type: str | None) -> str | None:82    """The normalized content-type when it denotes a non-text blob, else83    ``None``. Callers use the returned value in the error message."""84    if not content_type:85        return None86    bare = content_type.split(";", 1)[0].strip().lower()87    if bare in _BINARY_TYPES or bare.startswith(_BINARY_TYPE_PREFIXES):88        return bare89    return None90 91 92async def read_bounded(93    response: httpx.Response,94    max_bytes: int | None = None,95) -> tuple[bytes, bool]:96    """Read a streaming response up to ``max_bytes``; ``(body, truncated)``.97 98    Must be called inside the ``client.stream(...)`` context. Stopping99    early closes the connection, so a 2GB download costs at most100    ``max_bytes`` of transfer and memory.101    """102    cap = max_bytes if max_bytes is not None else max_fetch_bytes()103    chunks: list[bytes] = []104    total = 0105    async for chunk in response.aiter_bytes():106        chunks.append(chunk)107        total += len(chunk)108        if total >= cap:109            return b"".join(chunks)[:cap], True110    return b"".join(chunks), False111 112 113def decode_body(response: httpx.Response, body: bytes) -> str:114    """Decode a bounded body with the response's declared charset.115 116    ``errors="replace"`` because a truncated multi-byte sequence at the117    cap boundary must not raise. No chardet sniffing on the (possibly118    huge) body — absent/unknown charset falls back to UTF-8, matching119    the dominant real-world default.120    """121    encoding = response.charset_encoding or "utf-8"122    try:123        return body.decode(encoding, errors="replace")124    except LookupError:125        return body.decode("utf-8", errors="replace")126 127 128async def non_public_url_error(url: str) -> str:129    """Reason *url* must not be fetched, else ``""``.130 131    ``web_fetch`` is auto-approved in the terminal's risk gate, so a URL that132    arrives from page content — the prompt-injection path for a research agent133    — is requested without a human ever seeing it. Unguarded, the target may be134    a cloud metadata endpoint or any service on the deployment's private135    network, and with ``JINA_API_KEY`` set the internal URL is handed to the136    scrape provider before the fetch is even attempted.137 138    Reuses ``download_file``'s vetting so both tools share one definition of139    "public": http(s) only, no credentials in the URL, and EVERY resolved140    address global, so a split-horizon DNS answer cannot slip a private address141    through. Fail-closed — a name that cannot be resolved cannot be vetted.142    Resolution is off-loaded because it blocks.143 144    Set ``FRONTIER_AGENT_ALLOW_PRIVATE_FETCH=1`` to fetch a localhost or145    intranet service deliberately.146    """147    refusal, _addresses = await vet_public_url(url)148    return refusal149 150 151async def vet_public_url(url: str) -> tuple[str, tuple[str, ...]]:152    """``(refusal, validated_addresses)`` for *url*.153 154    Returning the addresses is what makes DNS-rebinding defence possible:155    validating a name and then letting the client resolve it a second time is a156    TOCTOU — an attacker-controlled resolver can answer with a public address157    for the check and a private one for the connection. Callers hand these158    addresses to :func:`pin_to_address` so the socket goes where the check159    looked. ``download_file`` has always pinned for this reason; the scrape160    paths now do too.161    """162    if (os.getenv("FRONTIER_AGENT_ALLOW_PRIVATE_FETCH") or "").strip() == "1":163        return "", ()164    from plugins.tools._download_runner import DownloadError, _validate_public_url165    try:166        addresses = await asyncio.to_thread(_validate_public_url, url)167    except DownloadError as exc:168        return str(exc), ()169    if isinstance(addresses, str):   # older single-address contract170        addresses = (addresses,)171    return "", tuple(addresses)172 173 174#: Headers that authenticate the caller and must not follow a redirect to a175#: different origin. httpx strips these itself when it follows redirects; a176#: hand-rolled hop loop has to do it explicitly or it leaks the credential to177#: whatever host the first origin names.178_CREDENTIAL_HEADERS = frozenset({179    "authorization", "cookie", "proxy-authorization", "www-authenticate",180})181 182 183def _origin(url: str) -> tuple[str, str, int | None]:184    parts = httpx.URL(url)185    return (parts.scheme, parts.host, parts.port)186 187 188def strip_cross_origin_credentials(189    headers: dict[str, str], from_url: str, to_url: str,190) -> dict[str, str]:191    """Drop caller credentials when a redirect hop changes origin.192 193    Same-origin hops keep them, so an authenticated fetch that redirects within194    one host still works.195    """196    try:197        if _origin(from_url) == _origin(to_url):198            return headers199    except Exception:200        pass201    return {202        name: value for name, value in headers.items()203        if name.lower() not in _CREDENTIAL_HEADERS204    }205 206 207def pin_to_address(208    url: str, addresses: tuple[str, ...], headers: dict[str, str],209) -> tuple[str, dict[str, str], dict[str, object]]:210    """Rewrite a request to dial an already-validated address.211 212    Returns ``(url, headers, extensions)``. The address replaces the URL host so213    no second DNS lookup can happen, while the original hostname is preserved214    twice over: in the ``Host`` header (virtual-host routing) and in the215    ``sni_hostname`` extension, which drives TLS SNI *and* the certificate216    hostname check — so a pinned HTTPS request still fails closed on a217    mismatched certificate.218 219    A no-op when there is nothing to pin (the private-fetch opt-in returns no220    addresses) or the URL is already literal-IP.221    """222    if not addresses:223        return url, headers, {}224    parsed = httpx.URL(url)225    hostname = parsed.host226    if not hostname or hostname == addresses[0]:227        return url, headers, {}228    pinned = str(parsed.copy_with(host=addresses[0]))229    return (230        pinned,231        {**headers, "Host": parsed.netloc.decode("ascii")},232        {"sni_hostname": hostname},233    )234 235 236#: Redirect hops a scrape may follow. Matches httpx's own default ceiling.237MAX_REDIRECT_HOPS = 20238 239 240class RedirectRefused(Exception):241    """A redirect hop pointed somewhere ``non_public_url_error`` refuses."""242 243 244async def next_hop(response: httpx.Response, current_url: str) -> str | None:245    """The vetted URL a 30x response redirects to, or ``None`` if it is final.246 247    Automatic redirect following defeats the URL guard: only the FIRST URL is248    vetted, so a public attacker-controlled page can answer 302 → localhost or249    a cloud metadata endpoint and the client follows it. Callers therefore250    disable ``follow_redirects`` and walk the chain through this, which vets251    every hop with the same rule the initial URL passed.252 253    Raises :class:`RedirectRefused` rather than returning the reason, so a254    refused hop cannot be mistaken for "no more redirects" and silently treated255    as a successful fetch.256    """257    if not response.is_redirect:258        return None259    location = response.headers.get("location", "").strip()260    if not location:261        return None262    target = str(httpx.URL(current_url).join(location))263    refusal = await non_public_url_error(target)264    if refusal:265        raise RedirectRefused(refusal)266    return target267 268 269__all__ = [270    "DEFAULT_MAX_FETCH_BYTES",271    "MAX_REDIRECT_HOPS",272    "RedirectRefused",273    "binary_content_type",274    "blocked_download_url",275    "decode_body",276    "max_fetch_bytes",277    "next_hop",278    "non_public_url_error",279    "pin_to_address",280    "read_bounded",281    "strip_cross_origin_credentials",282    "vet_public_url",283]284