fix(web): keep credential-bearing URLs away from the remote Jina reader

This commit is contained in:
shixi-li
2026-08-13 02:26:22 +09:00
committed by Xubin Ren
parent 498070d036
commit 31a71d6cd5
4 changed files with 235 additions and 2 deletions
+8
View File
@@ -1921,6 +1921,14 @@ Create a key at [serper.dev](https://serper.dev). You can also set `SERPER_API_K
nanobot by default uses [Jina Reader](https://jina.ai/reader/), a third-party API, to convert arbitrary pages into Markdown format for easy digestion by the LLM, with a local fallback based on [readability-lxml](https://github.com/buriy/python-readability) if the former fails. nanobot by default uses [Jina Reader](https://jina.ai/reader/), a third-party API, to convert arbitrary pages into Markdown format for easy digestion by the LLM, with a local fallback based on [readability-lxml](https://github.com/buriy/python-readability) if the former fails.
> [!NOTE]
> Using the remote reader means the fetched URL itself is disclosed to the
> third-party service. URLs that visibly carry credentials (userinfo, signed-URL
> or token-style query parameters) are detected and fetched locally instead, but
> secrets embedded in a URL's *path* (for example bot-token or webhook-style
> URLs) cannot be reliably detected. Set `useJinaReader: false` if fetched URLs
> must never leave the machine.
If you want to always use the local conversion, you can force it using: If you want to always use the local conversion, you can force it using:
```json ```json
+4
View File
@@ -81,6 +81,10 @@ in the WebUI or logs.
- Web fetch and HTTP MCP share an SSRF guard. - Web fetch and HTTP MCP share an SSRF guard.
- Private, loopback, link-local, and cloud metadata addresses are blocked by - Private, loopback, link-local, and cloud metadata addresses are blocked by
default. default.
- With `useJinaReader` enabled (the default), fetched URLs are disclosed to the
remote reader service. Credential-bearing URLs (userinfo or token/signature
query parameters) are fetched locally instead; path-embedded secrets cannot
be detected, so disable the remote reader when URLs must stay local.
- Add `tools.ssrfWhitelist` only for narrow trusted CIDRs. - Add `tools.ssrfWhitelist` only for narrow trusted CIDRs.
- Do not give public chat users unrestricted web and shell access without - Do not give public chat users unrestricted web and shell access without
review. review.
+42 -2
View File
@@ -11,7 +11,7 @@ import os
import re import re
from collections.abc import Callable from collections.abc import Callable
from typing import Any, cast from typing import Any, cast
from urllib.parse import quote, urljoin, urlparse from urllib.parse import parse_qsl, quote, urljoin, urlparse
import httpx import httpx
from loguru import logger from loguru import logger
@@ -148,6 +148,33 @@ def _unsafe_url_request_error(exc: BaseException) -> str | None:
return str(exc) if isinstance(exc, UnsafeURLRequestError) else None return str(exc) if isinstance(exc, UnsafeURLRequestError) else None
# Forwarding a URL to the remote Jina reader discloses it to a third party, so
# URLs that embed credential material (userinfo, signed-URL parameters, token
# or key query values) must never leave the machine. Matching is by parameter
# name: over-matching only costs the local readability fallback, while
# under-matching leaks a secret.
_CREDENTIAL_QUERY_PARAMS = frozenset({
"access_token", "apikey", "api_key", "auth", "authorization",
"client_secret", "id_token", "key", "password", "passwd", "pwd",
"refresh_token", "secret", "sig", "signature", "token",
})
_CREDENTIAL_QUERY_PREFIXES = ("x-amz-", "x-goog-")
def _url_carries_credentials(url: str) -> bool:
try:
parsed = urlparse(url)
except ValueError:
return True
if parsed.username is not None or parsed.password is not None:
return True
for name, _value in parse_qsl(parsed.query, keep_blank_values=True):
lowered = name.lower()
if lowered in _CREDENTIAL_QUERY_PARAMS or lowered.startswith(_CREDENTIAL_QUERY_PREFIXES):
return True
return False
async def _get_with_safe_redirects( async def _get_with_safe_redirects(
client: httpx.AsyncClient, client: httpx.AsyncClient,
url: str, url: str,
@@ -1082,13 +1109,26 @@ class WebFetchTool(Tool):
async def _fetch_jina(self, url: str, max_chars: int) -> str | None: async def _fetch_jina(self, url: str, max_chars: int) -> str | None:
"""Try fetching via Jina Reader API. Returns None on failure.""" """Try fetching via Jina Reader API. Returns None on failure."""
if _url_carries_credentials(url):
redacted = urlparse(url)
logger.debug(
"Skipping Jina Reader for {}://{}{}: URL carries credential material",
redacted.scheme,
redacted.hostname or "",
redacted.path,
)
return None
# httpx already drops the fragment when building the request; strip it
# explicitly so client-side-only data (OAuth implicit flows put tokens
# there) stays out of this path even if the transport changes.
forwarded_url = url.split("#", 1)[0]
try: try:
headers = {"Accept": "application/json", "User-Agent": self.user_agent} headers = {"Accept": "application/json", "User-Agent": self.user_agent}
jina_key = os.environ.get("JINA_API_KEY", "") jina_key = os.environ.get("JINA_API_KEY", "")
if jina_key: if jina_key:
headers["Authorization"] = f"Bearer {jina_key}" headers["Authorization"] = f"Bearer {jina_key}"
async with httpx.AsyncClient(proxy=self.proxy, timeout=20.0) as client: async with httpx.AsyncClient(proxy=self.proxy, timeout=20.0) as client:
r = await client.get(f"https://r.jina.ai/{url}", headers=headers) r = await client.get(f"https://r.jina.ai/{forwarded_url}", headers=headers)
if r.status_code == 429: if r.status_code == 429:
logger.debug("Jina Reader rate limited, falling back to readability") logger.debug("Jina Reader rate limited, falling back to readability")
return None return None
+181
View File
@@ -0,0 +1,181 @@
"""Tests that the Jina Reader path never discloses credential-bearing URLs."""
from __future__ import annotations
import json
import socket
from unittest.mock import patch
import pytest
from nanobot.agent.tools import web as web_module
from nanobot.agent.tools.web import (
WebFetchTool,
_url_carries_credentials,
)
def _fake_resolve_public(hostname, port, family=0, type_=0):
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("93.184.216.34", 0))]
class _RecordingJinaClient:
"""Fake httpx.AsyncClient that records every requested URL."""
requested: list[str] = []
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def get(self, url, **kwargs):
_RecordingJinaClient.requested.append(url)
class _Response:
status_code = 200
def raise_for_status(self):
pass
def json(self):
return {"data": {"title": "T", "content": "body", "url": url}}
return _Response()
@pytest.fixture
def jina_client():
_RecordingJinaClient.requested = []
with patch("nanobot.agent.tools.web.httpx.AsyncClient", _RecordingJinaClient):
yield _RecordingJinaClient
@pytest.mark.parametrize(
"url",
[
"https://user:secret@example.com/report",
"https://user@example.com/report",
"https://example.com/download?token=abc123",
"https://example.com/download?access_token=abc123",
"https://example.com/doc?Signature=xyz&Expires=1700000000",
"https://bucket.s3.amazonaws.com/key?X-Amz-Signature=deadbeef",
"https://storage.googleapis.com/o/file?X-Goog-Signature=deadbeef",
"https://example.com/blob?sig=sas-token-material",
"https://maps.example.com/api?key=AIzaFixture",
],
)
def test_credential_urls_are_detected(url: str) -> None:
assert _url_carries_credentials(url) is True
@pytest.mark.parametrize(
"url",
[
"https://example.com/",
"https://example.com/watch?v=abc123",
"https://example.com/search?q=token+design&page=2",
"https://example.com/page#section-3",
],
)
def test_plain_urls_are_not_detected(url: str) -> None:
assert _url_carries_credentials(url) is False
async def test_jina_is_skipped_for_credential_urls(jina_client) -> None:
tool = WebFetchTool()
result = await tool._fetch_jina(
"https://example.com/download?token=abc123", max_chars=1000
)
assert result is None
assert jina_client.requested == []
async def test_jina_is_skipped_for_userinfo_urls(jina_client) -> None:
tool = WebFetchTool()
result = await tool._fetch_jina(
"https://user:secret@example.com/report", max_chars=1000
)
assert result is None
assert jina_client.requested == []
async def test_jina_still_used_for_plain_urls(jina_client) -> None:
tool = WebFetchTool()
result = await tool._fetch_jina("https://example.com/watch?v=abc123", max_chars=1000)
assert result is not None
assert json.loads(result)["extractor"] == "jina"
assert jina_client.requested == [
"https://r.jina.ai/https://example.com/watch?v=abc123"
]
async def test_fragment_is_never_forwarded(jina_client) -> None:
tool = WebFetchTool()
result = await tool._fetch_jina(
"https://example.com/page?q=1#access_token=leaked", max_chars=1000
)
assert result is not None
assert jina_client.requested == ["https://r.jina.ai/https://example.com/page?q=1"]
async def test_execute_fetches_credential_urls_locally(monkeypatch) -> None:
"""The tool boundary: a credential URL must use the local extractor and
produce zero requests to the remote reader."""
tool = WebFetchTool()
requested: list[str] = []
class FakeStreamResponse:
status_code = 200
headers = {"content-type": "text/html"}
url = "https://example.com/download"
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
class FakeResponse:
status_code = 200
url = "https://example.com/download"
text = "<html><head><title>T</title></head><body><p>ok</p></body></html>"
headers = {"content-type": "text/html"}
is_redirect = False
def raise_for_status(self):
return None
class FakeClient:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def stream(self, method, url, headers=None, **kwargs):
requested.append(str(url))
return FakeStreamResponse()
async def get(self, url, headers=None, **kwargs):
requested.append(str(url))
return FakeResponse()
monkeypatch.setattr(tool, "_extract_readable_html", lambda html, mode: "ok")
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
result = await tool.execute(url="https://example.com/download?token=abc123")
data = json.loads(result)
assert data["extractor"] == "readability"
assert all("r.jina.ai" not in url for url in requested)