File size: 10,265 Bytes
9d0fd45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
"""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",
]