frontier-agent-demo / plugins /tools /_bounded_fetch.py
christlurker's picture
FrontierAgent react demo
9d0fd45
Raw
History Blame Contribute Delete
10.3 kB
"""Bounded HTTP body reads for the web_fetch tool family."""
from __future__ import annotations
import asyncio
import os
import httpx
# Byte ceiling for one response body. Env-tunable for callers that
# legitimately need bigger text payloads.
DEFAULT_MAX_FETCH_BYTES = 5 * 1024 * 1024
def max_fetch_bytes() -> int:
raw = (os.getenv("WEB_FETCH_MAX_BYTES") or "").strip()
if raw:
try:
value = int(raw)
if value > 0:
return value
except ValueError:
pass
return DEFAULT_MAX_FETCH_BYTES
# Content-types that are data blobs, never extractable page text. PDF is
# deliberately absent: Jina converts PDFs to text, so blocking it here
# would regress the primary scrape path. Prefix match on the bare type
# (parameters like ``; charset=`` stripped by the caller helper).
_BINARY_TYPE_PREFIXES = ("image/", "audio/", "video/", "font/")
_BINARY_TYPES = frozenset({
"application/zip",
"application/octet-stream",
"application/x-tar",
"application/gzip",
"application/x-gzip",
"application/x-bzip2",
"application/x-xz",
"application/x-7z-compressed",
"application/x-rar-compressed",
"application/x-hdf",
"application/x-hdf5",
"application/vnd.ms-cab-compressed",
"application/x-matlab-data",
"application/x-msdownload",
"application/wasm",
})
# URL path extensions that denote dataset / archive / binary downloads.
# Screened BEFORE any request is issued (no Jina call, no bytes on the
# wire). Text-ish data formats (.csv/.json/.txt/.xml) are deliberately
# absent: small ones are legitimate fetch targets and the byte cap
# bounds the large ones. PDF stays allowed (Jina converts it).
_BLOCKED_URL_EXTENSIONS = (
".zip", ".tar", ".tgz", ".tar.gz", ".tar.bz2", ".tar.xz",
".gz", ".bz2", ".xz", ".7z", ".rar", ".zst",
".mat", ".h5", ".hdf5", ".npz", ".npy", ".pkl", ".pickle",
".pt", ".pth", ".onnx", ".safetensors", ".parquet", ".feather",
".whl", ".deb", ".rpm", ".dmg", ".iso", ".exe", ".msi", ".apk",
)
def blocked_download_url(url: str) -> str | None:
"""The matched extension when ``url``'s path names a dataset/archive
download, else ``None``. Match is on the URL *path* (query/fragment
stripped), so ``?format=zip`` params don't false-positive."""
try:
from urllib.parse import unquote, urlsplit
path = unquote(urlsplit(url).path).strip().lower()
except Exception:
return None
for ext in _BLOCKED_URL_EXTENSIONS:
if path.endswith(ext):
return ext
return None
def binary_content_type(content_type: str | None) -> str | None:
"""The normalized content-type when it denotes a non-text blob, else
``None``. Callers use the returned value in the error message."""
if not content_type:
return None
bare = content_type.split(";", 1)[0].strip().lower()
if bare in _BINARY_TYPES or bare.startswith(_BINARY_TYPE_PREFIXES):
return bare
return None
async def read_bounded(
response: httpx.Response,
max_bytes: int | None = None,
) -> tuple[bytes, bool]:
"""Read a streaming response up to ``max_bytes``; ``(body, truncated)``.
Must be called inside the ``client.stream(...)`` context. Stopping
early closes the connection, so a 2GB download costs at most
``max_bytes`` of transfer and memory.
"""
cap = max_bytes if max_bytes is not None else max_fetch_bytes()
chunks: list[bytes] = []
total = 0
async for chunk in response.aiter_bytes():
chunks.append(chunk)
total += len(chunk)
if total >= cap:
return b"".join(chunks)[:cap], True
return b"".join(chunks), False
def decode_body(response: httpx.Response, body: bytes) -> str:
"""Decode a bounded body with the response's declared charset.
``errors="replace"`` because a truncated multi-byte sequence at the
cap boundary must not raise. No chardet sniffing on the (possibly
huge) body — absent/unknown charset falls back to UTF-8, matching
the dominant real-world default.
"""
encoding = response.charset_encoding or "utf-8"
try:
return body.decode(encoding, errors="replace")
except LookupError:
return body.decode("utf-8", errors="replace")
async def non_public_url_error(url: str) -> str:
"""Reason *url* must not be fetched, else ``""``.
``web_fetch`` is auto-approved in the terminal's risk gate, so a URL that
arrives from page content — the prompt-injection path for a research agent
— is requested without a human ever seeing it. Unguarded, the target may be
a cloud metadata endpoint or any service on the deployment's private
network, and with ``JINA_API_KEY`` set the internal URL is handed to the
scrape provider before the fetch is even attempted.
Reuses ``download_file``'s vetting so both tools share one definition of
"public": http(s) only, no credentials in the URL, and EVERY resolved
address global, so a split-horizon DNS answer cannot slip a private address
through. Fail-closed — a name that cannot be resolved cannot be vetted.
Resolution is off-loaded because it blocks.
Set ``FRONTIER_AGENT_ALLOW_PRIVATE_FETCH=1`` to fetch a localhost or
intranet service deliberately.
"""
refusal, _addresses = await vet_public_url(url)
return refusal
async def vet_public_url(url: str) -> tuple[str, tuple[str, ...]]:
"""``(refusal, validated_addresses)`` for *url*.
Returning the addresses is what makes DNS-rebinding defence possible:
validating a name and then letting the client resolve it a second time is a
TOCTOU — an attacker-controlled resolver can answer with a public address
for the check and a private one for the connection. Callers hand these
addresses to :func:`pin_to_address` so the socket goes where the check
looked. ``download_file`` has always pinned for this reason; the scrape
paths now do too.
"""
if (os.getenv("FRONTIER_AGENT_ALLOW_PRIVATE_FETCH") or "").strip() == "1":
return "", ()
from plugins.tools._download_runner import DownloadError, _validate_public_url
try:
addresses = await asyncio.to_thread(_validate_public_url, url)
except DownloadError as exc:
return str(exc), ()
if isinstance(addresses, str): # older single-address contract
addresses = (addresses,)
return "", tuple(addresses)
#: Headers that authenticate the caller and must not follow a redirect to a
#: different origin. httpx strips these itself when it follows redirects; a
#: hand-rolled hop loop has to do it explicitly or it leaks the credential to
#: whatever host the first origin names.
_CREDENTIAL_HEADERS = frozenset({
"authorization", "cookie", "proxy-authorization", "www-authenticate",
})
def _origin(url: str) -> tuple[str, str, int | None]:
parts = httpx.URL(url)
return (parts.scheme, parts.host, parts.port)
def strip_cross_origin_credentials(
headers: dict[str, str], from_url: str, to_url: str,
) -> dict[str, str]:
"""Drop caller credentials when a redirect hop changes origin.
Same-origin hops keep them, so an authenticated fetch that redirects within
one host still works.
"""
try:
if _origin(from_url) == _origin(to_url):
return headers
except Exception:
pass
return {
name: value for name, value in headers.items()
if name.lower() not in _CREDENTIAL_HEADERS
}
def pin_to_address(
url: str, addresses: tuple[str, ...], headers: dict[str, str],
) -> tuple[str, dict[str, str], dict[str, object]]:
"""Rewrite a request to dial an already-validated address.
Returns ``(url, headers, extensions)``. The address replaces the URL host so
no second DNS lookup can happen, while the original hostname is preserved
twice over: in the ``Host`` header (virtual-host routing) and in the
``sni_hostname`` extension, which drives TLS SNI *and* the certificate
hostname check — so a pinned HTTPS request still fails closed on a
mismatched certificate.
A no-op when there is nothing to pin (the private-fetch opt-in returns no
addresses) or the URL is already literal-IP.
"""
if not addresses:
return url, headers, {}
parsed = httpx.URL(url)
hostname = parsed.host
if not hostname or hostname == addresses[0]:
return url, headers, {}
pinned = str(parsed.copy_with(host=addresses[0]))
return (
pinned,
{**headers, "Host": parsed.netloc.decode("ascii")},
{"sni_hostname": hostname},
)
#: Redirect hops a scrape may follow. Matches httpx's own default ceiling.
MAX_REDIRECT_HOPS = 20
class RedirectRefused(Exception):
"""A redirect hop pointed somewhere ``non_public_url_error`` refuses."""
async def next_hop(response: httpx.Response, current_url: str) -> str | None:
"""The vetted URL a 30x response redirects to, or ``None`` if it is final.
Automatic redirect following defeats the URL guard: only the FIRST URL is
vetted, so a public attacker-controlled page can answer 302 → localhost or
a cloud metadata endpoint and the client follows it. Callers therefore
disable ``follow_redirects`` and walk the chain through this, which vets
every hop with the same rule the initial URL passed.
Raises :class:`RedirectRefused` rather than returning the reason, so a
refused hop cannot be mistaken for "no more redirects" and silently treated
as a successful fetch.
"""
if not response.is_redirect:
return None
location = response.headers.get("location", "").strip()
if not location:
return None
target = str(httpx.URL(current_url).join(location))
refusal = await non_public_url_error(target)
if refusal:
raise RedirectRefused(refusal)
return target
__all__ = [
"DEFAULT_MAX_FETCH_BYTES",
"MAX_REDIRECT_HOPS",
"RedirectRefused",
"binary_content_type",
"blocked_download_url",
"decode_body",
"max_fetch_bytes",
"next_hop",
"non_public_url_error",
"pin_to_address",
"read_bounded",
"strip_cross_origin_credentials",
"vet_public_url",
]